Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c6b5c20a | ||
|
|
1efa1d29d1 | ||
|
|
0e51356a7d | ||
|
|
69ab8e125b | ||
|
|
8ab29800db | ||
|
|
cf542b7416 | ||
|
|
ae4fe72b2e |
@@ -23,13 +23,13 @@ from .state import AppContext
|
||||
from .ui.widgets import tidy_popup
|
||||
from .theme import current_palette, set_active_theme, stylesheet
|
||||
from .core.task_scheduler import TaskScheduler
|
||||
from .presentation.dashboard.dashboard_tab import DashboardTab
|
||||
from .presentation.graph.structure_graph_view import StructureGraphView
|
||||
from .presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
from .ui.cowork_tab import CoworkTab
|
||||
from .ui.dashboard_tab import DashboardTab
|
||||
from .ui.monitoring_tab import MonitoringTab
|
||||
from .ui.schedule_task_tab import ScheduleTaskTab
|
||||
from .ui.settings_dialog import SettingsDialog
|
||||
from .ui.sidebar import HistorySidebar
|
||||
from .ui.structure_graph_view import StructureGraphView
|
||||
from .ui.workspace_tab import WorkspaceTab
|
||||
|
||||
ASSETS = Path(__file__).resolve().parent / "assets"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Conversation use case: the lifecycle of one agent turn (EPIC R04)."""
|
||||
"""Conversation use case: the lifecycle of one agent turn (EPIC R04) and the
|
||||
tool approval policy every turn's tool calls go through (EPIC R05)."""
|
||||
|
||||
from .conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
TurnResult,
|
||||
)
|
||||
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
|
||||
|
||||
__all__ = ["ConversationApplicationService", "TurnResult"]
|
||||
__all__ = ["ConversationApplicationService", "TurnResult", "ToolPolicyGateway", "ConfirmGate"]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ToolPolicyGateway - one confirm/deny decision path for every tool call
|
||||
(R05-T03).
|
||||
|
||||
Today "does this tool call need the user's OK first" is answered by a
|
||||
different hand-written check per engine:
|
||||
|
||||
* ``core/chat_agent.py::run_cowork`` — ``name in ("run_command",
|
||||
"install_package")``, a literal tuple.
|
||||
* ``core/code_agent.py::run_code`` — ``name in (WRITE_TOOLS | MS365_WRITE_TOOLS)``,
|
||||
a set built from two other hand-maintained sets.
|
||||
* MCP/connector tools (``core/mcp_client.py``, ``core/ext_connectors.py``) —
|
||||
no check at all; ``chat_agent.py`` calls ``extra_executor(name, args)``
|
||||
directly.
|
||||
|
||||
Three answers to the same question, and the third one is a real gap: an MCP
|
||||
tool that deletes files or calls an external API today runs with zero
|
||||
confirmation even when the user turned "confirm before running commands" on.
|
||||
|
||||
This gateway answers the question from data (:class:`~domain.tools.tool_descriptor.ToolCapability`
|
||||
via a :class:`~domain.tools.tool_registry.ToolRegistry`) instead of a literal
|
||||
name list, so registering a tool with the right capability is what gates it -
|
||||
nothing to remember at each new call site. R05-T04 is what actually registers
|
||||
MCP/connector tools with a capability; this module only needs the mechanism
|
||||
to exist.
|
||||
|
||||
Pure Python: no Qt, no direct dialog. The actual approval prompt stays exactly
|
||||
what it is today - a ``gate`` object with a ``.request(payload) -> bool``
|
||||
method, supplied by the presentation layer (Settings' "confirm before running
|
||||
commands" wires it up, or None for auto-run) - this module only decides
|
||||
WHEN to ask it, never how to render the question.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
|
||||
from cowork_local.domain.tools import ToolCapability, ToolRegistry
|
||||
|
||||
|
||||
class ConfirmGate(Protocol):
|
||||
"""Shape of the existing ``PermissionGate`` both engines already use."""
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool: ...
|
||||
|
||||
|
||||
class ToolPolicyGateway:
|
||||
"""Decides whether a tool call needs approval, for ONE calling surface.
|
||||
|
||||
``gated_capabilities`` is what makes this per-surface: Cowork only ever
|
||||
asked about ``run_command``/``install_package`` (capability ``EXECUTE``),
|
||||
while the Code tab additionally confirms plain file writes (capability
|
||||
``WRITE``). Passing the wrong set here would silently change which tools
|
||||
prompt for approval - see the callers in ``core/chat_agent.py`` and
|
||||
``core/code_agent.py`` for the exact sets that preserve today's behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
|
||||
self._registry = registry
|
||||
self._gated_capabilities = gated_capabilities
|
||||
|
||||
def requires_confirmation(self, name: str) -> bool:
|
||||
"""True when ``name``'s declared capabilities overlap this surface's
|
||||
gated set. An unregistered tool never requires confirmation through
|
||||
this path - callers that must fail safe on unknown tools check
|
||||
``name in registry`` themselves (see R05-T04's MCP wrapping, which
|
||||
registers every tool it exposes before any call can reach here)."""
|
||||
return bool(self._registry.capabilities_for(name) & self._gated_capabilities)
|
||||
|
||||
def allow(self, name: str, gate: Optional[ConfirmGate], payload: Dict[str, Any]) -> bool:
|
||||
"""True when the call may proceed.
|
||||
|
||||
``gate is None`` preserves each engine's existing "no gate wired -
|
||||
auto-run" behavior; a tool outside ``gated_capabilities`` is never
|
||||
asked about, matching read-only tools "never confirm" today.
|
||||
``payload`` is whatever ``gate.request(...)`` already expects at that
|
||||
call site (the two engines use slightly different dict shapes) - this
|
||||
gateway only decides WHETHER to call it, never reshapes the payload.
|
||||
"""
|
||||
if gate is None or not self.requires_confirmation(name):
|
||||
return True
|
||||
return bool(gate.request(payload))
|
||||
|
||||
|
||||
__all__ = ["ToolPolicyGateway", "ConfirmGate"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Read-only query services for monitoring/dashboard screens (EPIC R08).
|
||||
|
||||
⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_
|
||||
Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives
|
||||
under ``application/monitoring/`` alongside the Dashboard split — but the
|
||||
SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/``
|
||||
to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did
|
||||
not exist yet when Team Hoa reached R08-T13, so creating it here does not
|
||||
collide with any file Team Nam has written — same situation R06-T02 flagged
|
||||
for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's
|
||||
planned ``atomic_json_file.py``. Team Nam should confirm when they start
|
||||
R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or
|
||||
should move once Monitoring's own query service exists.
|
||||
"""
|
||||
|
||||
from .dashboard_query_service import DashboardQueryService
|
||||
|
||||
__all__ = ["DashboardQueryService"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""DashboardQueryService - read-only usage/cost queries for the Dashboard
|
||||
screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
196-199/256-261/284-322 of the original 437-line file: ``_pricing``,
|
||||
``_period_range``'s date-math, and the ``period_totals``/``period_breakdown``
|
||||
calls ``_refresh_chart`` made directly).
|
||||
|
||||
``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_
|
||||
pricing.py`` directly from FIVE different methods spread across what is now
|
||||
three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``,
|
||||
``habits_widget.py``) — each recomputing the same merged pricing dict. This
|
||||
service is the one place that merge happens now; the three widgets share it
|
||||
instead of each calling ``core.usage_tracker``/``core.model_pricing`` on
|
||||
their own.
|
||||
|
||||
Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module
|
||||
already) rather than reimplementing any of its date/cost math.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
class DashboardQueryService:
|
||||
"""Usage/cost queries scoped to one ``AppContext``.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext`` — read for ``ctx.config`` (pricing table,
|
||||
currency, budget) and nothing else; this class does no I/O of
|
||||
its own beyond what ``core.usage_tracker`` already does.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
self.ctx = ctx
|
||||
|
||||
def pricing(self) -> Dict[str, Any]:
|
||||
"""The merged price table (defaults + user overrides), synced from
|
||||
Monitoring's model-pricing table first so cost figures always agree
|
||||
between the two screens."""
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
mp.sync_to_usage(self.ctx.config)
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def period_range(self, granularity: str, offset: int) -> Tuple[date, date]:
|
||||
"""The SELECTED period as an inclusive ``(start, end)`` date range —
|
||||
drives every widget on the screen (cards, chart, habits)."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
start, end = ut.period_bounds(granularity, offset)
|
||||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||||
|
||||
def summary(self, start: date, end: date) -> Dict[str, Any]:
|
||||
"""Everything the stat cards + habits panel need for one period:
|
||||
the raw events, ``usage_tracker.summarize``'s aggregate stats, the
|
||||
per-bucket costs, and their total — computed once so both widgets
|
||||
read the same numbers instead of loading events twice."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events(start, end)
|
||||
pricing = self.pricing()
|
||||
stats = ut.summarize(events)
|
||||
costs = ut.cost_usd_events(events, pricing)
|
||||
return {
|
||||
"events": events,
|
||||
"pricing": pricing,
|
||||
"stats": stats,
|
||||
"costs": costs,
|
||||
"total_cost": sum(costs.values()),
|
||||
}
|
||||
|
||||
def chart_series(self, granularity: str, offset: int, metric: str
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""``(label, value)`` points for the spline chart — WEEK -> 7 days,
|
||||
MONTH -> weeks, YEAR -> 12 months, in whichever ``metric``
|
||||
("tokens" | "cost") was selected."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events() # all events; breakdown slices by period
|
||||
pricing = self.pricing()
|
||||
parts = ut.period_breakdown(events, granularity, pricing, offset=offset)
|
||||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value
|
||||
return [(row[0], float(row[mi + 1])) for row in parts]
|
||||
|
||||
def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]:
|
||||
"""``(tokens, cost)`` totals for one period — used to compute the
|
||||
vs-previous-period delta the chart's reference line shows."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events()
|
||||
return ut.period_totals(events, granularity, self.pricing(), offset)
|
||||
|
||||
def period_range_label(self, granularity: str, offset: int) -> str:
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.period_range_label(granularity, offset)
|
||||
|
||||
def budget_status(self):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.budget_status(self.ctx.config)
|
||||
|
||||
def set_budget(self, amount: float, currency: str) -> None:
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
ut.set_budget(self.ctx.config, amount, currency)
|
||||
|
||||
|
||||
__all__ = ["DashboardQueryService"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Application services for Schedule Task (EPIC R07)."""
|
||||
|
||||
from .ai_task_planner_service import AiTaskPlannerService
|
||||
from .task_application_service import MoveResult, RunNowResult, TaskApplicationService
|
||||
|
||||
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult", "AiTaskPlannerService"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AiTaskPlannerService - AI-generate / import task lists, outside the widget
|
||||
(R07-T05).
|
||||
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` already delegates the
|
||||
actual planning to two existing pure functions —
|
||||
``core/ai_task_planner.py::plan_tasks`` (natural-language description ->
|
||||
task dicts, via the active provider) and
|
||||
``core/task_import.py::import_tasks`` (Excel/CSV/JSON -> task dicts) — so
|
||||
this service does not reimplement either. What it DOES own is one small
|
||||
piece of business logic that currently only exists inside the dialog's
|
||||
``AgentWorker`` job closure (``_generate``'s ``job()``): every AI-generated
|
||||
task must carry the SAME file/link attachments the user attached to the
|
||||
request, so they're available again at run time, not just visible to the
|
||||
planner while it drafts the task list. Leaving that step trapped in a Qt
|
||||
worker closure means it can only be exercised by driving the real dialog;
|
||||
here it's a plain, independently testable method.
|
||||
|
||||
Pure Python: no Qt import. The provider is a constructor-injected factory
|
||||
(``() -> Provider``, no arguments — matches ``AppContext.build_active_
|
||||
provider``), the same dependency-inversion shape
|
||||
``application/conversations/conversation_application_service.py`` (R04-T03)
|
||||
uses for ITS provider factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||||
|
||||
ProviderFactory = Callable[[], Any]
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
|
||||
class AiTaskPlannerService:
|
||||
"""AI task generation + file/Excel/CSV/JSON import, for
|
||||
``presentation/scheduling/ai_task_creator_dialog.py`` and
|
||||
``ai_task_import_dialog.py`` (R08-T11) to call instead of importing
|
||||
``core.ai_task_planner``/``core.task_import`` directly.
|
||||
|
||||
Args:
|
||||
provider_factory: ``() -> Provider``. Production passes
|
||||
``AppContext.build_active_provider``; tests pass a lambda
|
||||
returning a :class:`FakeProvider`.
|
||||
"""
|
||||
|
||||
def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None:
|
||||
self._provider_factory = provider_factory
|
||||
|
||||
def plan(
|
||||
self,
|
||||
description: str,
|
||||
*,
|
||||
file_paths: Sequence[str] = (),
|
||||
links: Sequence[str] = (),
|
||||
provider: Any = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Turn ``description`` into a list of NOT-yet-saved task dicts.
|
||||
|
||||
``provider`` overrides the constructor's factory for this one call
|
||||
(useful for tests, or a caller that already resolved a provider);
|
||||
omit it to use the injected factory. Raises ``RuntimeError`` when
|
||||
no provider is available at all, or when the model's reply had no
|
||||
parseable task list (same error ``core.ai_task_planner.plan_tasks``
|
||||
already raises).
|
||||
"""
|
||||
resolved = provider if provider is not None else self._resolve_provider()
|
||||
from cowork_local.core.ai_task_planner import plan_tasks
|
||||
|
||||
planned = plan_tasks(resolved, description, cancel=cancel)
|
||||
# Attachments apply to EVERY generated task so they're still there
|
||||
# when the task actually runs, not just while the planner drafts it
|
||||
# (see module docstring — this used to only happen inside the
|
||||
# dialog's worker closure).
|
||||
for task in planned:
|
||||
task["input"]["file_paths"] = list(file_paths)
|
||||
task["input"]["links"] = list(links)
|
||||
return planned
|
||||
|
||||
def import_file(self, path: Union[str, Path]) -> List[Dict[str, Any]]:
|
||||
"""Excel/CSV/JSON -> NOT-yet-saved task dicts, auto-chained in file
|
||||
order. Raises ``ValueError`` with a human-readable message on an
|
||||
unusable/unsupported file (same contract
|
||||
``core.task_import.import_tasks`` already has)."""
|
||||
from cowork_local.core.task_import import import_tasks
|
||||
|
||||
return import_tasks(path)
|
||||
|
||||
def _resolve_provider(self) -> Any:
|
||||
if self._provider_factory is None:
|
||||
raise RuntimeError("No provider available to plan tasks.")
|
||||
return self._provider_factory()
|
||||
|
||||
|
||||
__all__ = ["AiTaskPlannerService"]
|
||||
@@ -0,0 +1,172 @@
|
||||
"""TaskApplicationService - task CRUD + dispatch, outside the widget (R07-T04).
|
||||
|
||||
``ui/schedule_task_tab.py`` currently does all of this by importing
|
||||
``core/tasks.py`` module functions directly and calling
|
||||
``self.scheduler.run_now(...)`` inline inside Qt slot methods
|
||||
(``_run_now``, ``_context_menu``'s duplicate/pause/delete branches,
|
||||
``_on_task_dropped``'s per-lane business rules). None of it is Qt — it's
|
||||
plain CRUD plus a few small rules ("a manual task never auto-runs",
|
||||
"dropping a card on Done disables its schedule so it won't re-fire",
|
||||
"dropping on Scheduled with no time set needs the editor, not a silent
|
||||
no-op") — but it can only be exercised today by driving the real widget.
|
||||
|
||||
This service is the seam ``presentation/scheduling/kanban_board_widget.py``
|
||||
(R08-T11) calls instead: same rules, same
|
||||
:class:`~infrastructure.persistence.json.task_repository_impl.TaskRepository`
|
||||
underneath, testable with no Qt at all.
|
||||
|
||||
Pure Python: no Qt import. ``run_now`` dispatch is a plain injected callable
|
||||
(production wires ``TaskScheduler.run_now``; tests inject a stub), the same
|
||||
constructor-injection shape ``application/conversations/conversation_
|
||||
application_service.py`` (R04-T03) uses for its provider factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
# "TaskRepository" here is a Protocol-shaped name, not an import: this module
|
||||
# only calls .get/.save/.delete/.duplicate, so any object with that shape
|
||||
# (the real infrastructure.persistence.json.task_repository_impl.TaskRepository,
|
||||
# or a test double) works without this file importing infrastructure/ at
|
||||
# module scope.
|
||||
RunNowFn = Callable[[str], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunNowResult:
|
||||
"""Outcome of asking a task to run immediately.
|
||||
|
||||
``reason`` is one of ``""`` (ok), ``"not_found"``, ``"manual_task"``
|
||||
(manual tasks never auto-run — spec: they exist to be run by a human),
|
||||
``"no_scheduler"`` (no ``run_now`` callable was wired in), or
|
||||
``"already_running"`` (the scheduler's own dedupe rejected it).
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveResult:
|
||||
"""Outcome of dropping a task card onto a Kanban lane
|
||||
(``move_to_status``). The caller (kanban widget) uses the flags to decide
|
||||
what to show — a full re-render, a "task is running" toast, or opening
|
||||
the task editor — without re-deriving the business rule itself."""
|
||||
|
||||
task: Optional[Dict[str, Any]]
|
||||
blocked: bool = False # dropped while already running — ignored
|
||||
ran_now: bool = False # dropped on the Running lane — dispatched
|
||||
run_now_result: Optional[RunNowResult] = None
|
||||
needs_schedule: bool = False # dropped on Scheduled with no run_at set — needs editing
|
||||
|
||||
|
||||
class TaskApplicationService:
|
||||
"""CRUD + dispatch for Schedule Task, backed by a ``TaskRepository``.
|
||||
|
||||
Args:
|
||||
repository: a ``TaskRepository``-shaped object (``.get``, ``.save``,
|
||||
``.delete``, ``.duplicate``). Production passes
|
||||
``infrastructure.persistence.json.task_repository_impl.
|
||||
TaskRepository()``; tests pass one scoped to a ``tmp_path``.
|
||||
run_now: ``(task_id) -> bool``. Production passes
|
||||
``TaskScheduler.run_now``; ``None`` means no scheduler is wired
|
||||
(matches the widget's own "no scheduler" guard today).
|
||||
"""
|
||||
|
||||
def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None:
|
||||
self._repository = repository
|
||||
self._run_now = run_now
|
||||
|
||||
# -- single-task actions ------------------------------------------------ #
|
||||
def run_now(self, task_id: str) -> RunNowResult:
|
||||
"""Dispatch ``task_id`` immediately. A "Run now" always counts as
|
||||
manual approval (spec §13) — this is the ONE path that bypasses
|
||||
``execution.requires_approval``, same as the scheduler's own
|
||||
``run_now`` already does."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return RunNowResult(False, "not_found")
|
||||
if task.get("task_type") == "manual":
|
||||
return RunNowResult(False, "manual_task")
|
||||
if self._run_now is None:
|
||||
return RunNowResult(False, "no_scheduler")
|
||||
ok = self._run_now(task_id)
|
||||
return RunNowResult(ok, "" if ok else "already_running")
|
||||
|
||||
def duplicate(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""A saved copy with a fresh identity — see
|
||||
``core/tasks.py::duplicate_task`` for what's preserved/reset."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
dup = self._repository.duplicate(task)
|
||||
self._repository.save(dup)
|
||||
return dup
|
||||
|
||||
def toggle_pause(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Pause a task, or resume a paused one back to Backlog (matches
|
||||
``ui/schedule_task_tab.py``'s context-menu action exactly — resuming
|
||||
does NOT restore whatever status the task had before pausing, only
|
||||
Backlog, so the user re-schedules explicitly rather than a stale
|
||||
schedule silently re-firing)."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
task["status"] = "backlog" if task.get("status") == "paused" else "paused"
|
||||
self._repository.save(task)
|
||||
return task
|
||||
|
||||
def delete(self, task_id: str) -> bool:
|
||||
if self._repository.get(task_id) is None:
|
||||
return False
|
||||
self._repository.delete(task_id)
|
||||
return True
|
||||
|
||||
def bulk_delete(self, task_ids: List[str]) -> int:
|
||||
"""Delete every id in ``task_ids``; returns how many actually
|
||||
existed (mirrors ``_confirm_and_delete_selected``'s best-effort
|
||||
loop — a stale id in the selection doesn't abort the rest)."""
|
||||
return sum(1 for tid in task_ids if self.delete(tid))
|
||||
|
||||
# -- Kanban drag/drop ----------------------------------------------------- #
|
||||
def move_to_status(self, task_id: str, new_status: str) -> Optional[MoveResult]:
|
||||
"""Apply the business rule behind dropping a card into a lane
|
||||
(``ui/schedule_task_tab.py::_on_task_dropped``, moved here so it's
|
||||
testable without a real ``QListWidget`` drag gesture):
|
||||
|
||||
* already running -> the drop is ignored (a running task can't be
|
||||
re-filed by dragging it).
|
||||
* dropped on Running -> runs it now (counts as manual approval).
|
||||
* dropped on Done -> marks it done AND disables its schedule, so a
|
||||
repeating task marked done by hand doesn't quietly re-fire later.
|
||||
* dropped on Scheduled with no ``run_at`` set yet -> saved as-is but
|
||||
flagged ``needs_schedule`` — the caller should open the editor
|
||||
rather than leave a Scheduled card that will never actually run.
|
||||
* anything else -> plain status change.
|
||||
"""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task.get("status") == "running":
|
||||
return MoveResult(task=task, blocked=True)
|
||||
if new_status == "running":
|
||||
result = self.run_now(task_id)
|
||||
return MoveResult(task=self._repository.get(task_id), ran_now=True, run_now_result=result)
|
||||
if new_status == "done":
|
||||
task["status"] = "done"
|
||||
task["schedule"]["enabled"] = False
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task)
|
||||
task["status"] = new_status
|
||||
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
||||
if task["schedule"].get("run_at"):
|
||||
task["schedule"]["enabled"] = True
|
||||
else:
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task, needs_schedule=True)
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task)
|
||||
|
||||
|
||||
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult"]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06, R08)."""
|
||||
|
||||
from .ai_edit_output import parse_ai_output, split_code_block
|
||||
from .file_preview_helpers import is_probably_text, pptx_available, read_text
|
||||
from .file_workspace_service import FileWorkspaceService
|
||||
from .graph_index_service import extract_file_contents, pdf_to_markdown
|
||||
|
||||
__all__ = [
|
||||
"FileWorkspaceService",
|
||||
"read_text",
|
||||
"is_probably_text",
|
||||
"pptx_available",
|
||||
"split_code_block",
|
||||
"parse_ai_output",
|
||||
"pdf_to_markdown",
|
||||
"extract_file_contents",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Parse an AI file-edit reply into its parts (R08-T12, moved out of
|
||||
``ui/folder_tab.py`` — that file's module-level ``_split_code_block``/
|
||||
``_parse_ai_output``, lines 1536-1562 of the original 1587-line file). Pure
|
||||
string parsing, no Qt — used by ``presentation/folder/ai_file_editor_dialog.py``
|
||||
to turn a model's raw reply into a proposed edit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
def split_code_block(text: str) -> Tuple[Optional[str], str]:
|
||||
"""Split an AI reply into ``(file_content, summary)``. ``file_content``
|
||||
is the first fenced code block (the edited file); ``summary`` is any
|
||||
prose before it. Returns ``(None, text)`` when there's no code block."""
|
||||
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
|
||||
if not m:
|
||||
return None, (text or "")
|
||||
return m.group(1), (text[:m.start()].strip())
|
||||
|
||||
|
||||
def parse_ai_output(text: str) -> Tuple[Optional[str], Optional[str], str, List[Tuple[str, str]]]:
|
||||
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
|
||||
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> =>
|
||||
<path>`` lines request generated illustration images (relative paths)."""
|
||||
content, summary = split_code_block(text)
|
||||
target = None
|
||||
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
|
||||
if m:
|
||||
target = m.group(1).strip().strip("`\"'")
|
||||
image_gens = []
|
||||
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
|
||||
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
|
||||
# Strip the directive lines out of the shown summary.
|
||||
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
|
||||
return target, content, summary, image_gens
|
||||
|
||||
|
||||
__all__ = ["split_code_block", "parse_ai_output"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Pure helpers for previewing a file (R08-T12, moved out of
|
||||
``ui/folder_tab.py`` — that file's module-level functions
|
||||
``_read_text``/``_is_probably_text``/``_pptx_available``, lines 1519-1533 and
|
||||
1565-1587 of the original 1587-line file). No Qt, no widget state — the
|
||||
"is this file text? is pptx editing available?" questions the preview
|
||||
manager asks before it decides how to render something.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_PPTX_READY = None # cached: pptx-editing library available (after auto-install)
|
||||
|
||||
|
||||
def pptx_available() -> bool:
|
||||
"""True when python-pptx is importable. If it's MISSING, auto-download &
|
||||
install it (via deps.ensure_module) so pptx editing 'just works' — cached
|
||||
so the (one-time) install is attempted only once."""
|
||||
global _PPTX_READY
|
||||
if _PPTX_READY is None:
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return f"[could not read file: {exc}]"
|
||||
|
||||
|
||||
def is_probably_text(path: str) -> bool:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
chunk = f.read(4096)
|
||||
except OSError:
|
||||
return False
|
||||
if b"\x00" in chunk:
|
||||
return False
|
||||
try:
|
||||
chunk.decode("utf-8")
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
# Latin-ish text still edits fine via errors="replace"; only reject on
|
||||
# a hard binary signal (NUL above), so most source files pass.
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["pptx_available", "read_text", "is_probably_text"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""FileWorkspaceService - the safe file operations File Explorer and the AI
|
||||
File Editor need, outside the agent tool loop (R06-T05).
|
||||
|
||||
``ui/folder_tab.py`` (File Explorer) and the AI File Editor dialog need the
|
||||
exact same guarantees the agent's tools already have — path containment
|
||||
inside the workspace, precise context-anchored edits, syntax warnings on a
|
||||
bad Python write — but today that logic only exists wired to a model's tool
|
||||
call (``core/tools.py::execute_tool``). A UI action that isn't a tool call
|
||||
(browsing the tree, applying an AI-suggested diff from a review dialog) has
|
||||
no equivalent entry point of its own.
|
||||
|
||||
This service IS that entry point. It reuses ``core/tools.py::execute_tool``
|
||||
verbatim - same dispatch table, same ``ToolContext`` containment check, same
|
||||
audit-log entry, same Python-syntax warning on write/edit - rather than
|
||||
re-implementing any of it, so a fix to one path fixes both. It only adds the
|
||||
:class:`~domain.workspaces.workspace_session.WorkspaceSession` seam: which
|
||||
workspace root a call is scoped to is decided by the session, not by
|
||||
whichever folder a widget happens to have open.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class FileWorkspaceService:
|
||||
"""File operations scoped to one :class:`WorkspaceSession`.
|
||||
|
||||
Read-only by name (``list_tree``/``read_preview``) vs. writing
|
||||
(``write_file``/``apply_edit``) mirrors the same READ/WRITE split
|
||||
``domain/tools/tool_registry.py`` uses for the agent's own tools - a
|
||||
caller that only wants to browse never accidentally has write access.
|
||||
"""
|
||||
|
||||
def __init__(self, session) -> None: # WorkspaceSession - see module docstring
|
||||
self._session = session
|
||||
|
||||
def list_tree(self, rel: str = ".") -> Dict[str, Any]:
|
||||
"""Entries at ``rel`` (default: the workspace root)."""
|
||||
return self._execute("list_dir", {"path": rel})
|
||||
|
||||
def read_preview(self, rel: str) -> Dict[str, Any]:
|
||||
"""A text file's content (truncated by
|
||||
``infrastructure/filesystem/file_tools.py::MAX_READ_BYTES``, same as
|
||||
the agent's ``read_file`` tool)."""
|
||||
return self._execute("read_file", {"path": rel})
|
||||
|
||||
def write_file(self, rel: str, content: str) -> Dict[str, Any]:
|
||||
"""Create or fully overwrite ``rel``."""
|
||||
return self._execute("write_file", {"path": rel, "content": content})
|
||||
|
||||
def apply_edit(self, rel: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet in an existing file - the same
|
||||
context-anchored algorithm the agent's ``edit_file`` tool uses, so an
|
||||
AI-suggested diff applies with the same precision and the same
|
||||
"old_string not found / ambiguous" failure messages either path
|
||||
would give the caller."""
|
||||
return self._execute("edit_file", {
|
||||
"path": rel, "old_string": old_string, "new_string": new_string,
|
||||
"replace_all": replace_all,
|
||||
})
|
||||
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _tool_context(self):
|
||||
"""A ``ToolContext`` scoped to this session's workspace root.
|
||||
``flatten_writes=False`` (unlike Cowork's agent context) - File
|
||||
Explorer must preserve whatever subfolder structure the user is
|
||||
actually browsing, not collapse every write into the root."""
|
||||
from cowork_local.infrastructure.filesystem.tool_context import ToolContext
|
||||
|
||||
return ToolContext(self._session.workspace_root, flatten_writes=False)
|
||||
|
||||
def _execute(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Dispatch through ``core/tools.py::execute_tool`` - see the module
|
||||
docstring for why this delegates instead of reimplementing."""
|
||||
from cowork_local.core.tools import execute_tool
|
||||
|
||||
return execute_tool(self._tool_context(), name, args)
|
||||
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Temporary file-content extraction for Graph-RAG Q&A (R08-T14, moved out
|
||||
of ``ui/structure_graph_view.py`` — that file's module-level
|
||||
``_pdf_to_markdown``/``_extract_file_contents``, lines 964-1034 of the
|
||||
original 1035-line file). Runs inside the ask worker's job function so the
|
||||
answer is synthesized from real file content, not just the graph structure.
|
||||
|
||||
Pure Python: no Qt. Best-effort throughout (never raises) — a failed
|
||||
extraction degrades to "no content for this file", not a broken Q&A turn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def pdf_to_markdown(pdf_path: str, out_dir: str) -> Optional[str]:
|
||||
"""Convert a PDF to Markdown with opendataloader-pdf when available
|
||||
(richer structure than a plain text dump). Best-effort — returns None
|
||||
if the package isn't installed or the call fails, so the caller falls
|
||||
back to ``core/doc_extract.py``."""
|
||||
try:
|
||||
import opendataloader_pdf # optional; auto-installed elsewhere if present
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
|
||||
return None
|
||||
import opendataloader_pdf # noqa: F811
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for call in (
|
||||
lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
|
||||
generate_markdown=True),
|
||||
lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
|
||||
lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
|
||||
):
|
||||
try:
|
||||
call()
|
||||
break
|
||||
except TypeError:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
mds = list(out.rglob(Path(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
|
||||
for md in mds:
|
||||
try:
|
||||
return md.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def extract_file_contents(paths: List[str], cache: Dict[str, str], tmp_dir: str,
|
||||
max_files: int = 15, max_total: int = 120_000
|
||||
) -> Tuple[str, Dict[str, str]]:
|
||||
"""Read the ACTUAL content of ``paths`` (PDF -> markdown via
|
||||
opendataloader when available, else ``doc_extract`` for office/pdf/
|
||||
text). Returns ``(block, cache)`` — ``block`` is the concatenated
|
||||
content for the prompt (bounded), ``cache`` maps path -> text for
|
||||
reuse. Never raises."""
|
||||
from cowork_local.core import doc_extract
|
||||
|
||||
cache = dict(cache or {})
|
||||
parts, total = [], 0
|
||||
for p in paths[:max_files]:
|
||||
if total >= max_total:
|
||||
break
|
||||
text = cache.get(p)
|
||||
if text is None:
|
||||
try:
|
||||
if Path(p).suffix.lower() == ".pdf":
|
||||
text = pdf_to_markdown(p, tmp_dir)
|
||||
if not text:
|
||||
text, _n = doc_extract.extract_text(p)
|
||||
else:
|
||||
text, _n = doc_extract.extract_text(p)
|
||||
except Exception: # noqa: BLE001
|
||||
text = ""
|
||||
cache[p] = text or ""
|
||||
text = cache.get(p) or ""
|
||||
if not text:
|
||||
continue
|
||||
chunk = text[: max(0, max_total - total)]
|
||||
total += len(chunk)
|
||||
parts.append(f'--- {Path(p).name} ({p}) ---\n{chunk}')
|
||||
return ("\n\n".join(parts), cache)
|
||||
|
||||
|
||||
__all__ = ["pdf_to_markdown", "extract_file_contents"]
|
||||
+46
-10
@@ -11,6 +11,8 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
|
||||
from ..domain.tools import ToolCapability, default_registry
|
||||
from ..providers.base import Provider, ToolSpec
|
||||
from . import agent_roles
|
||||
from . import agent_security
|
||||
@@ -27,6 +29,13 @@ from .tools import TOOL_SPECS, ToolContext, _snapshot, describe_action, execute_
|
||||
# Generator / helper scripts — never a final deliverable in Cowork's output.
|
||||
_SCRIPT_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bat", ".ps1", ".rb", ".pl"}
|
||||
|
||||
# R05-T03/T04: replaces the literal ``name in ("run_command",
|
||||
# "install_package")`` check below with a capability lookup — EXECUTE is
|
||||
# exactly the capability those two (and only those two) built-in tools carry
|
||||
# (see domain/tools/tool_registry.py::BUILT_IN_CAPABILITIES). Copied per-turn
|
||||
# into ``turn_tool_policy`` inside run_cowork() once extra_tools are known.
|
||||
_COWORK_TOOL_REGISTRY = default_registry(TOOL_SPECS)
|
||||
|
||||
EmitFn = Callable[[Dict[str, Any]], None]
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
@@ -388,6 +397,19 @@ def run_cowork(
|
||||
jira=(security_config.data.get("jira") if security_config else None))
|
||||
extra_tools = extra_tools or []
|
||||
extra_names = {t.name for t in extra_tools}
|
||||
# R05-T04: MCP servers (core/mcp_client.py) and unified connectors
|
||||
# (core/ext_connectors.py) — everything that arrives here as extra_tools —
|
||||
# advertise no standard risk metadata, so each is tagged with the same
|
||||
# conservative default (WRITE|EXECUTE|NETWORK) domain/tools/tool_registry.py
|
||||
# uses for any unclassified tool. Copying the built-in registry per turn
|
||||
# (cheap - under 20 entries) rather than mutating the shared module-level
|
||||
# one keeps different turns' extra_tools from leaking into each other.
|
||||
from ..domain.tools import ToolDescriptor, ToolRegistry
|
||||
from ..domain.tools.tool_registry import UNKNOWN_SOURCE_CAPABILITIES
|
||||
_turn_registry = ToolRegistry(_COWORK_TOOL_REGISTRY.all())
|
||||
for _spec in extra_tools:
|
||||
_turn_registry.register(ToolDescriptor.from_spec(_spec, UNKNOWN_SOURCE_CAPABILITIES))
|
||||
turn_tool_policy = ToolPolicyGateway(_turn_registry, ToolCapability.EXECUTE)
|
||||
# update_plan drives the Plan panel (above Output); it produces no file.
|
||||
# Built-in tools the admin disabled (Monitoring → Tools) are filtered out.
|
||||
from .tools import enabled_tool_specs
|
||||
@@ -489,6 +511,18 @@ def run_cowork(
|
||||
preview = {"kind": "info", "title": name, "text": str(args)}
|
||||
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
|
||||
"preview": preview})
|
||||
# R05-T04: MCP/connector tools used to run with NO permission
|
||||
# check at all — this is what closes that gap. Same policy,
|
||||
# same gate object as the built-in tools below.
|
||||
if not turn_tool_policy.allow(
|
||||
name, gate, {"name": name, "args": args, "preview": preview}
|
||||
):
|
||||
result = {"ok": False, "output": "Rejected by user."}
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]})
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
|
||||
"content": result["output"]})
|
||||
continue
|
||||
result = extra_executor(name, args)
|
||||
emit({"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": result.get("ok", False), "output": result.get("output", "")})
|
||||
@@ -528,16 +562,18 @@ def run_cowork(
|
||||
# Permission Management (Sandbox Security Layer) — only when a
|
||||
# gate was actually supplied (Settings: "confirm before running
|
||||
# commands"); None preserves the pre-existing auto-run behavior.
|
||||
if gate is not None and name in ("run_command", "install_package"):
|
||||
approved = gate.request({"name": name, "args": args, "preview": preview})
|
||||
if not approved:
|
||||
result = {"ok": False, "output": "Rejected by user."}
|
||||
evt = {"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]}
|
||||
emit(evt)
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id,
|
||||
"name": name, "content": result["output"]})
|
||||
continue
|
||||
# R05-T03: gating is now capability-driven (see
|
||||
# turn_tool_policy above) instead of a literal name tuple.
|
||||
if not turn_tool_policy.allow(
|
||||
name, gate, {"name": name, "args": args, "preview": preview}
|
||||
):
|
||||
result = {"ok": False, "output": "Rejected by user."}
|
||||
evt = {"type": "tool_result", "id": tc_id, "name": name,
|
||||
"ok": False, "output": result["output"]}
|
||||
emit(evt)
|
||||
messages.append({"role": "tool", "tool_call_id": tc_id,
|
||||
"name": name, "content": result["output"]})
|
||||
continue
|
||||
|
||||
if name == "save_file":
|
||||
result = _do_save_file(output_dir, title, args)
|
||||
|
||||
+15
-4
@@ -12,6 +12,8 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
|
||||
from ..domain.tools import ToolCapability, ToolDescriptor, ToolRegistry
|
||||
from ..providers.base import Provider
|
||||
from . import agent_roles
|
||||
from . import agent_security
|
||||
@@ -225,6 +227,14 @@ def run_code(
|
||||
# read/list ms365 tools count as "read-only, never confirm". Names are
|
||||
# the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py).
|
||||
gated_tools = WRITE_TOOLS | MS365_WRITE_TOOLS
|
||||
# R05-T03/T04: ``gated_tools`` stays the authoritative name set (unchanged),
|
||||
# but the actual confirm decision now goes through the same
|
||||
# ToolPolicyGateway class run_cowork uses, instead of a separate
|
||||
# hand-rolled ``if name in gated_tools`` + direct ``gate.request(...)``.
|
||||
code_tool_policy = ToolPolicyGateway(
|
||||
ToolRegistry(ToolDescriptor(n, "", {}, ToolCapability.WRITE) for n in gated_tools),
|
||||
ToolCapability.WRITE,
|
||||
)
|
||||
# In PLAN mode, don't advertise write/run tools (analysis only).
|
||||
advertised = [t for t in all_tools if t.name not in gated_tools] if plan else all_tools
|
||||
has_memory = any(t.name.startswith("cmem_") for t in extra_tools)
|
||||
@@ -297,10 +307,11 @@ def run_code(
|
||||
agent_security.enforce_command(provider, name, args, security_config, emit,
|
||||
agent_kind="code")
|
||||
|
||||
if name in gated_tools:
|
||||
approved = gate.request({"id": tc_id, "name": name, "args": args, "preview": preview})
|
||||
else:
|
||||
approved = True # read-only tools (incl. codebase memory) never confirm
|
||||
# read-only tools (incl. codebase memory) never consult the gate —
|
||||
# code_tool_policy.requires_confirmation(name) is False for them.
|
||||
approved = code_tool_policy.allow(
|
||||
name, gate, {"id": tc_id, "name": name, "args": args, "preview": preview}
|
||||
)
|
||||
|
||||
if cancel():
|
||||
return messages
|
||||
|
||||
+9
-3
@@ -66,7 +66,9 @@ def save_conversation(
|
||||
"outputs": list(outputs or []),
|
||||
"messages": messages,
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, payload)
|
||||
return path
|
||||
|
||||
|
||||
@@ -78,15 +80,19 @@ def delete_conversation(path) -> None:
|
||||
|
||||
|
||||
def rename_conversation(path, new_title: str) -> None:
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
|
||||
data = load_conversation(path)
|
||||
data["title"] = new_title
|
||||
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
write_json(Path(path), data)
|
||||
|
||||
|
||||
def set_pinned(path, pinned: bool) -> None:
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
|
||||
data = load_conversation(path)
|
||||
data["pinned"] = bool(pinned)
|
||||
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
write_json(Path(path), data)
|
||||
|
||||
|
||||
def load_conversation(path: Path) -> Dict[str, Any]:
|
||||
|
||||
@@ -106,6 +106,13 @@ class McpServerConnection:
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""True while the connection's background thread (and therefore its
|
||||
event loop and subprocess) is still running — used by
|
||||
``infrastructure/mcp/mcp_source_manager.py`` (R05-T05) to tell a live
|
||||
cached connection from one whose subprocess already died."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
# ---- tools -----------------------------------------------------------
|
||||
def list_tool_specs(self) -> List[ToolSpec]:
|
||||
"""The server's tools, wrapped as :class:`ToolSpec` — the same shape
|
||||
|
||||
+5
-3
@@ -116,10 +116,12 @@ def new_project(name: str, description: str = "", instructions: str = "",
|
||||
|
||||
def save_project(project: Project, directory: Path = None) -> Path:
|
||||
directory = directory or PROJECTS_DIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{project.project_id}.json"
|
||||
path.write_text(json.dumps(asdict(project), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
# R06-T02: atomic write — a crash/kill between truncate and write used to
|
||||
# leave a half-written project.json that load_project() then silently
|
||||
# treats as "missing" (see infrastructure/persistence/json/atomic_write.py).
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, asdict(project))
|
||||
return path
|
||||
|
||||
|
||||
|
||||
+20
-10
@@ -17,7 +17,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .tasks import (
|
||||
advance_after_run, chain_action, dependencies_met, due_tasks, format_run_at,
|
||||
@@ -39,22 +39,32 @@ class TaskScheduler(QObject):
|
||||
# which fires before the worker thread has even begun).
|
||||
history_ready = Signal(str) # task_id
|
||||
|
||||
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None):
|
||||
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.tasks_dir = tasks_dir # None → default TASKS_DIR
|
||||
self._workers: Dict[str, AgentWorker] = {} # task_id → running worker
|
||||
self._retries: Dict[str, int] = {}
|
||||
self._session_ids: Dict[str, str] = {} # task_id → its run's History session id
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(TICK_MS)
|
||||
self._timer.timeout.connect(self.tick)
|
||||
# R07-T03: the QTimer this class used to own directly is now behind a
|
||||
# small clock interface (start/stop/pump) — see
|
||||
# platform/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
|
||||
# real one here keeps every existing production call site (which
|
||||
# never passes `clock=`) unchanged; tests inject
|
||||
# tests/fakes/fake_clock.py::FakeClock to control ticks by hand with
|
||||
# no Qt event loop running. Imported lazily so importing core.tasks/
|
||||
# core.task_scheduler for the Qt-free logic doesn't require the Qt
|
||||
# adapter module to even exist in a headless test context.
|
||||
if clock is None:
|
||||
from ..infrastructure.qt.qt_scheduler_clock import QtSchedulerClock
|
||||
clock = QtSchedulerClock(self)
|
||||
self._clock = clock
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------
|
||||
def start(self) -> None:
|
||||
self._recover_orphans()
|
||||
self.tick() # catch up overdue tasks right at app start
|
||||
self._timer.start()
|
||||
self._clock.start(TICK_MS, self.tick)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Request every running worker to stop, then WAIT (bounded) for them
|
||||
@@ -67,15 +77,15 @@ class TaskScheduler(QObject):
|
||||
``_on_done`` (the only place that writes the run into the task's
|
||||
history) never runs. The task's real output can already be sitting on
|
||||
disk while its history stays stuck on "running" forever. Pumping
|
||||
``processEvents()`` here lets that queued signal actually get
|
||||
delivered before the app finishes quitting.
|
||||
the clock here lets that queued signal actually get delivered before
|
||||
the app finishes quitting.
|
||||
"""
|
||||
self._timer.stop()
|
||||
self._clock.stop()
|
||||
deadline = time.monotonic() + STOP_WAIT_SECS
|
||||
while self._workers and time.monotonic() < deadline:
|
||||
for w in list(self._workers.values()):
|
||||
w.request_stop()
|
||||
QCoreApplication.processEvents()
|
||||
self._clock.pump()
|
||||
for w in list(self._workers.values()):
|
||||
w.wait(50)
|
||||
# Anything still alive past the deadline is abandoned here;
|
||||
|
||||
+29
-71
@@ -151,7 +151,14 @@ def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||
path = task_path(task["task_id"], directory)
|
||||
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# R07-T01: atomic write — same class of bug already fixed in
|
||||
# core/projects.py and core/history.py at R06-T02 (plain write_text has a
|
||||
# gap between truncate and write; a crash there leaves a half-written
|
||||
# tasks/<id>.json that load_task() then silently treats as "missing",
|
||||
# dropping the task). Lazy import to match the existing call sites and
|
||||
# avoid a core -> infrastructure import at module load time.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, task)
|
||||
return path
|
||||
|
||||
|
||||
@@ -287,36 +294,32 @@ def chain_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
|
||||
|
||||
# ---- schedule math --------------------------------------------------------
|
||||
def _is_excluded_day(dt: datetime, sched: Dict[str, Any]) -> bool:
|
||||
"""True when ``dt`` falls on a day this schedule must skip: a weekend
|
||||
(working_days_only) or a public holiday of the configured country."""
|
||||
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
return True
|
||||
if sched.get("skip_holidays"):
|
||||
# R07-T02: the actual date/cron math now lives in
|
||||
# domain/tasks/schedule_calculator.py::ScheduleCalculator (pure Python, unit
|
||||
# tested on its own — see tests/unit/test_schedule_calculator.py). Everything
|
||||
# below is a thin wrapper kept for backward compatibility: task_scheduler.py,
|
||||
# task_executors.py and ui/task_editor_dialog.py all still import these
|
||||
# module-level names from core.tasks, and core/holiday_calendar.py::is_holiday
|
||||
# / core/cron.py::Cron are only wired in HERE (lazily, matching the previous
|
||||
# lazy-import style) — domain/ is not allowed to import core/ (ADR-001 I2).
|
||||
_calculator: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_calculator():
|
||||
global _calculator
|
||||
if _calculator is None:
|
||||
from .cron import Cron
|
||||
from .holiday_calendar import is_holiday
|
||||
from ..domain.tasks.schedule_calculator import ScheduleCalculator
|
||||
|
||||
if is_holiday(dt.date(), sched.get("holiday_country", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _add_month(dt: datetime) -> datetime:
|
||||
import calendar
|
||||
|
||||
year = dt.year + (1 if dt.month == 12 else 0)
|
||||
month = 1 if dt.month == 12 else dt.month + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
_calculator = ScheduleCalculator(is_holiday=is_holiday, make_cron=Cron)
|
||||
return _calculator
|
||||
|
||||
|
||||
def shift_off_excluded_days(dt: datetime, sched: Dict[str, Any]) -> datetime:
|
||||
"""Push ``dt`` forward one day at a time until it lands on an allowed day
|
||||
(same time of day) — used for one-time schedules set on a weekend/holiday."""
|
||||
guard = 0
|
||||
while _is_excluded_day(dt, sched) and guard < 400:
|
||||
dt += timedelta(days=1)
|
||||
guard += 1
|
||||
return dt
|
||||
return _get_calculator().shift_off_excluded_days(dt, sched)
|
||||
|
||||
|
||||
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
||||
@@ -324,57 +327,12 @@ def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime
|
||||
(daily / weekly / monthly / cron), or None for one-shot schedules.
|
||||
Occurrences on excluded days (weekends with working_days_only, public
|
||||
holidays with skip_holidays+holiday_country) are skipped forward."""
|
||||
sched = task.get("schedule", {})
|
||||
repeat = sched.get("repeat_type", "none")
|
||||
|
||||
if repeat == "cron":
|
||||
from .cron import Cron, CronError
|
||||
|
||||
try:
|
||||
cron = Cron(sched.get("cron_expression") or "")
|
||||
except CronError:
|
||||
return None
|
||||
nxt = cron.next_after(after)
|
||||
guard = 0
|
||||
while nxt is not None and _is_excluded_day(nxt, sched) and guard < 400:
|
||||
nxt = cron.next_after(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
base = parse_run_at(sched.get("run_at"))
|
||||
if base is None:
|
||||
return None
|
||||
if repeat == "daily":
|
||||
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
||||
elif repeat == "weekly":
|
||||
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
||||
elif repeat == "monthly":
|
||||
advance = _add_month
|
||||
else:
|
||||
return None
|
||||
nxt = base
|
||||
while nxt <= after:
|
||||
nxt = advance(nxt)
|
||||
guard = 0
|
||||
while _is_excluded_day(nxt, sched) and guard < 400:
|
||||
nxt = advance(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
return _get_calculator().compute_next_run(task, after)
|
||||
|
||||
|
||||
def due_tasks(tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
||||
"""Tasks that should start now: Scheduled + schedule enabled + run_at due."""
|
||||
due = []
|
||||
for t in tasks:
|
||||
if t.get("status") != "scheduled":
|
||||
continue
|
||||
sched = t.get("schedule", {})
|
||||
if not sched.get("enabled"):
|
||||
continue
|
||||
run_at = parse_run_at(sched.get("run_at"))
|
||||
if run_at is not None and run_at <= now:
|
||||
due.append(t)
|
||||
return due
|
||||
return _get_calculator().due_tasks(tasks, now)
|
||||
|
||||
|
||||
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
|
||||
|
||||
+38
-312
@@ -3,79 +3,29 @@
|
||||
Every path is resolved relative to the working directory and must stay inside
|
||||
it (path-traversal is rejected). ``run_command`` executes inside the workdir
|
||||
with a timeout and captured output.
|
||||
|
||||
R05-T02: the actual handlers (``read_file``/``list_dir``/``write_file``/
|
||||
``edit_file``/``run_command``/``install_package``/``fetch_url``/
|
||||
``jira_search``/``jira_get_issue``) now live in
|
||||
``infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py``, split
|
||||
out of what used to be one big if/elif chain here. This module is the
|
||||
strangler-fig shim (ADR-001 section 4): it re-exports ``ToolContext``/
|
||||
``ToolError`` (actually defined in
|
||||
``infrastructure/filesystem/tool_context.py`` now) so every existing
|
||||
``from .tools import ToolContext`` keeps working, and ``execute_tool``
|
||||
dispatches through a small ``{name: handler}`` table built from the moved
|
||||
modules instead of the chain itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import difflib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..infrastructure.filesystem import command_tools, fetch_tools, file_tools
|
||||
from ..infrastructure.filesystem.command_tools import _snapshot # noqa: F401 - re-export, core/chat_agent.py imports this name
|
||||
from ..infrastructure.filesystem.tool_context import CancelFn, ToolContext, ToolError # noqa: F401 - re-export
|
||||
from ..providers.base import ToolSpec
|
||||
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
MAX_READ_BYTES = 200_000
|
||||
COMMAND_TIMEOUT = 120 # seconds
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _flatten_rel(rel: str) -> str:
|
||||
"""Collapse a sub-folder path down to a bare filename so the file lands in the
|
||||
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
|
||||
|
||||
Used by the Cowork agent (flatten_writes=True) so it can never create a
|
||||
per-session / per-chat / per-task output sub-folder: every deliverable stays
|
||||
directly in the single configured Output folder."""
|
||||
parts = Path(rel).parts
|
||||
if parts and parts[0] == ".scratch":
|
||||
return rel # temporary sandbox is allowed (and cleaned up afterwards)
|
||||
return Path(rel).name or rel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
workdir: Path
|
||||
flatten_writes: bool = False # Cowork: force every write into the workdir root
|
||||
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
|
||||
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
|
||||
# disk_mb), applied to every run_command/install_package this context runs.
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
jira: Optional[Dict[str, Any]] = None
|
||||
|
||||
def resolve(self, rel: str) -> Path:
|
||||
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
|
||||
if rel in ("", "."):
|
||||
return self.workdir
|
||||
candidate = (self.workdir / rel).expanduser()
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Invalid path: {rel} ({exc})")
|
||||
root = self.workdir.resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise ToolError(
|
||||
f"Refused: '{rel}' is outside the working folder ({root})."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tool specs advertised to the model
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -192,6 +142,23 @@ TOOL_SPECS: List[ToolSpec] = [
|
||||
# Actions gated by the permission gate in confirm mode (auto-approved in Auto-run).
|
||||
WRITE_TOOLS = {"write_file", "edit_file", "run_command", "install_package"}
|
||||
|
||||
# name -> handler(ctx, args[, cancel, on_output]) — built once from the split
|
||||
# infrastructure modules. Replaces the if/elif chain execute_tool used to be.
|
||||
_HANDLERS: Dict[str, Callable[..., Dict[str, Any]]] = {
|
||||
"read_file": file_tools.read_file,
|
||||
"list_dir": file_tools.list_dir,
|
||||
"write_file": file_tools.write_file,
|
||||
"edit_file": file_tools.edit_file,
|
||||
"run_command": command_tools.run_command,
|
||||
"install_package": command_tools.install_package,
|
||||
"fetch_url": fetch_tools.fetch_url,
|
||||
"jira_search": fetch_tools.jira_search,
|
||||
"jira_get_issue": fetch_tools.jira_get_issue,
|
||||
}
|
||||
# Handlers that accept the long-running (cancel, on_output) signature — every
|
||||
# other handler takes just (ctx, args).
|
||||
_CANCELLABLE = {"run_command", "install_package"}
|
||||
|
||||
|
||||
def enabled_tool_specs(security_config=None) -> List[ToolSpec]:
|
||||
"""The built-in TOOL_SPECS minus any the admin turned OFF in Monitoring →
|
||||
@@ -301,27 +268,14 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
|
||||
labels WHICH agent role made it."""
|
||||
from . import audit_log
|
||||
|
||||
handler = _HANDLERS.get(name)
|
||||
try:
|
||||
if name == "read_file":
|
||||
result = _read_file(ctx, args)
|
||||
elif name == "list_dir":
|
||||
result = _list_dir(ctx, args)
|
||||
elif name == "write_file":
|
||||
result = _write_file(ctx, args)
|
||||
elif name == "edit_file":
|
||||
result = _edit_file(ctx, args)
|
||||
elif name == "run_command":
|
||||
result = _run_command(ctx, args, cancel, on_output)
|
||||
elif name == "install_package":
|
||||
result = _install_package(ctx, args, cancel, on_output)
|
||||
elif name == "fetch_url":
|
||||
result = _fetch_url(ctx, args)
|
||||
elif name == "jira_search":
|
||||
result = _jira_search(ctx, args)
|
||||
elif name == "jira_get_issue":
|
||||
result = _jira_get_issue(ctx, args)
|
||||
else:
|
||||
if handler is None:
|
||||
result = {"ok": False, "output": f"Tool not found: {name}"}
|
||||
elif name in _CANCELLABLE:
|
||||
result = handler(ctx, args, cancel, on_output)
|
||||
else:
|
||||
result = handler(ctx, args)
|
||||
except ToolError as exc:
|
||||
result = {"ok": False, "output": str(exc)}
|
||||
except Exception as exc: # defensive: a tool must never crash the agent
|
||||
@@ -331,234 +285,6 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
|
||||
return result
|
||||
|
||||
|
||||
def _fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
use. Honors the Sandbox Security Layer's "Block network" policy."""
|
||||
url = str(args.get("url", "")).strip()
|
||||
if not url:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
"(\"Allow the agent to fetch URLs\").")}
|
||||
# A pasted Jira issue link on the CONNECTED Jira host is read via the
|
||||
# authenticated API (so private issues resolve, not a login page). Public
|
||||
# links / any other URL fall through to the normal fetcher below.
|
||||
from . import jira_tool
|
||||
if jira_tool.is_jira_issue_url(ctx.jira, url):
|
||||
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
|
||||
from .link_fetch import fetch_link_preview
|
||||
|
||||
return {"ok": True, "output": fetch_link_preview(url)}
|
||||
|
||||
|
||||
def _jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from . import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
int(args.get("max_results", 25) or 25))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
|
||||
"output": out}
|
||||
|
||||
|
||||
def _jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from . import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
|
||||
"output": out}
|
||||
|
||||
|
||||
def _read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
target = ctx.resolve(str(args.get("path", "")))
|
||||
if not target.exists():
|
||||
return {"ok": False, "output": f"File not found: {args.get('path')}"}
|
||||
data = target.read_bytes()[:MAX_READ_BYTES]
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
return {"ok": True, "output": text}
|
||||
|
||||
|
||||
def _list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ".") or ".")
|
||||
target = ctx.resolve(rel)
|
||||
# A missing/not-yet-created path is NOT a tool failure — report it as an
|
||||
# ordinary result so the agent can create it or pick another path and keep
|
||||
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
|
||||
# Co4E flows and could stall a step on a recoverable situation.
|
||||
if not target.exists():
|
||||
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
|
||||
if target.is_file():
|
||||
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
|
||||
entries = []
|
||||
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
marker = "/" if child.is_dir() else ""
|
||||
entries.append(f"{child.name}{marker}")
|
||||
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
|
||||
|
||||
|
||||
def _check_python_syntax(target: Path, content: str) -> str:
|
||||
"""Return a short warning if ``content`` is invalid Python, else ''.
|
||||
|
||||
Catches syntax errors the instant a .py file is written/edited — before the
|
||||
agent wastes a whole run_command round-trip just to get the same error back
|
||||
from a traceback."""
|
||||
if target.suffix.lower() not in (".py", ".pyw"):
|
||||
return ""
|
||||
try:
|
||||
ast.parse(content, filename=str(target))
|
||||
return ""
|
||||
except SyntaxError as exc:
|
||||
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
|
||||
|
||||
|
||||
def _write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
content = str(args.get("content", ""))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A .xlsx is a binary package — build a REAL workbook from the content
|
||||
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
|
||||
if target.suffix.lower() in (".xlsx", ".xlsm"):
|
||||
from . import xlsx_write
|
||||
if xlsx_write.build_xlsx_from_text(target, content):
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote spreadsheet {rel} ({target.name})."}
|
||||
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
|
||||
"write a .csv instead, or use a generator script."}
|
||||
target.write_text(content, encoding="utf-8")
|
||||
warning = _check_python_syntax(target, content)
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
|
||||
|
||||
|
||||
def _edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet inside an existing file (precise patch edit)."""
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
if not target.exists():
|
||||
return {"ok": False,
|
||||
"output": f"File not found: {rel} — use write_file to create it."}
|
||||
old = str(args.get("old_string", ""))
|
||||
new = str(args.get("new_string", ""))
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not old:
|
||||
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "output": f"Could not read file: {exc}"}
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
|
||||
"text to replace, including indentation/whitespace.")}
|
||||
if count > 1 and not replace_all:
|
||||
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
|
||||
"context to make it unique, or set replace_all=true.")}
|
||||
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
n = count if replace_all else 1
|
||||
warning = _check_python_syntax(target, updated)
|
||||
return {"ok": True,
|
||||
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
|
||||
|
||||
|
||||
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
|
||||
on_output: Optional[Callable[[str], None]] = None) -> Optional[str]:
|
||||
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
|
||||
``ctx.sandbox``); returns its python path, or None to use the app's own."""
|
||||
if not ctx.sandbox:
|
||||
return None
|
||||
from .deps import ensure_project_venv
|
||||
|
||||
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
|
||||
return str(py) if py else None
|
||||
|
||||
|
||||
def _install_package(ctx: ToolContext, args: Dict[str, Any], cancel: Optional[CancelFn] = None,
|
||||
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||
from .deps import pip_install
|
||||
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
return {"ok": ok, "output": f"{head}\n{detail}"}
|
||||
|
||||
|
||||
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
|
||||
".idea", ".mypy_cache", ".pytest_cache"}
|
||||
|
||||
|
||||
def _snapshot(workdir: Path) -> Dict[str, Any]:
|
||||
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
|
||||
snap: Dict[str, Any] = {}
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(str(workdir)):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
|
||||
for fn in filenames:
|
||||
full = os.path.join(dirpath, fn)
|
||||
try:
|
||||
st = os.stat(full)
|
||||
snap[full] = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
if len(snap) > 5000:
|
||||
return snap
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
|
||||
from .deps import network_blocked_env, run_cancellable, sandbox_env
|
||||
from .sandbox_manager import SandboxManager, ExecutionConfig
|
||||
from ..security.command_risk_classifier import classify_command
|
||||
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return {"ok": False, "output": "Empty command."}
|
||||
|
||||
# --- Security validation pipeline ---
|
||||
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
|
||||
if risk.blocked:
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# Route through SandboxManager for risk-based isolation
|
||||
mgr = SandboxManager(ExecutionConfig(
|
||||
enabled=True,
|
||||
block_network_by_default=ctx.block_network,
|
||||
is_cowork_mode=ctx.flatten_writes,
|
||||
))
|
||||
sandbox_result = mgr.run(
|
||||
command=command,
|
||||
workdir=str(ctx.workdir),
|
||||
block_network=ctx.block_network,
|
||||
timeout_sec=COMMAND_TIMEOUT,
|
||||
cancel=cancel,
|
||||
)
|
||||
# Sandbox ALWAYS executes (never double-run). Return its result directly.
|
||||
if sandbox_result.get("sandbox") == "blocked":
|
||||
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
|
||||
out = sandbox_result.get("stdout", "").strip() or "(no output)"
|
||||
err = sandbox_result.get("stderr", "")
|
||||
rc = sandbox_result.get("returncode", -1)
|
||||
if err:
|
||||
out = f"{out}\n{err}" if out else err
|
||||
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
|
||||
|
||||
|
||||
def _short_json(obj: Any, limit: int = 500) -> str:
|
||||
import json
|
||||
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# BÁO CÁO KẾT QUẢ — TEAM HOA: EPIC R05, R06, R07, R08 (phần Team Hoa)
|
||||
|
||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **Team**: 🟢 Team Hoa — Workspace, Filesystem, Scheduling & Tool Registry
|
||||
* **Nhánh**: `feature/teamhoa/r05-r08` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04 của Team Duy; đổi tên từ `feature/teamhoa/r05-r06` sau khi gộp thêm R07/R08)
|
||||
* **Thời gian thực hiện**: 21/08/2026 21:40 → 27/08/2026 20:52
|
||||
* **Ngày báo cáo**: 27/08/2026 (bản gộp, thay thế `BaoCao_TeamHoa_R05_R06.md` và `BaoCao_TeamHoa_R07_R08.md`)
|
||||
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Tóm tắt điều hành
|
||||
|
||||
Hoàn tất **toàn bộ 19/19 task thuộc phạm vi Team Hoa** trên 4 EPIC: **R05** (Tool, MCP & Connector Policy), **R06** (Workspace, Filesystem & History Isolation), **R07** (Scheduling & Workflow Runtime), **R08** (UI/Application Separation — phần Team Hoa, T11→T14).
|
||||
|
||||
| Chỉ số | Kết quả |
|
||||
| :--- | :--- |
|
||||
| Task hoàn thành | **19/19** (R05: 5, R06: 5, R07: 5, R08: 4 — không tính R07-T06/R08-T01→T10 thuộc Team Duy/Team Nam) |
|
||||
| File thay đổi | 92+ (phần lớn file mới) |
|
||||
| Test cuối cùng | **377 pass / 4 fail** (xem tiến trình chi tiết ở mục 4) |
|
||||
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
|
||||
| File production > 400 dòng (file mới) | **0** — lớn nhất `presentation/graph/graph_renderer.py` 391 dòng |
|
||||
| `python -c "import cowork_local.app"` | **OK** sau mọi task |
|
||||
|
||||
**3 lỗi thật phát hiện và sửa**, **1 quyết định kiến trúc đổi so với plan gốc (xác nhận bằng thực nghiệm)** — chi tiết mục 5.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kết quả theo từng EPIC
|
||||
|
||||
### 🔹 EPIC R05 — Tool, MCP & Connector Policy (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R05-T01/T02 | `domain/tools/{tool_descriptor,tool_registry}.py`, `infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py` | Tách if/elif dispatcher của `core/tools.py`; `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict), 566 ➔ 291 dòng. |
|
||||
| R05-T03 | `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` | Thay 2 chỗ check hardcode riêng biệt (`chat_agent.py`, `code_agent.py`) bằng 1 lookup capability chung. |
|
||||
| R05-T04 | Sửa `core/chat_agent.py`, `core/mcp_client.py` | **Thay đổi hành vi có chủ đích** — xem mục 5, Lỗi 1. |
|
||||
| R05-T05 | `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` | Tách lifecycle connection MCP khỏi `state.py::AppContext`. |
|
||||
|
||||
### 🔹 EPIC R06 — Workspace, Filesystem & History Isolation (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R06-T01 | `domain/workspaces/workspace_session.py::WorkspaceSession` | Snapshot bất biến (project_id/workspace_root/sandbox_dir/allowed_paths) + `is_allowed(path)`. |
|
||||
| R06-T02 | `infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py` | **Sửa bug thật** — xem mục 5, Lỗi 2. |
|
||||
| R06-T03 | `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` | Đặt tên cho quy ước `.scratch` đã có sẵn. |
|
||||
| R06-T04 | Sửa `ui/chat_panel.py` | **Sửa race condition thật** — xem mục 5, Lỗi 3. |
|
||||
| R06-T05 | `application/workspaces/file_workspace_service.py::FileWorkspaceService` | Seam cho File Explorer/AI Editor gọi `execute_tool` giống agent — **chưa có call site thật lúc R06 xong; đã nối dây ở R08-T12** (xem mục 5). |
|
||||
|
||||
### 🔹 EPIC R07 — Scheduling & Workflow Runtime (5/5, phạm vi Team Hoa)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R07-T01 | `infrastructure/persistence/json/task_repository_impl.py::TaskRepository` | Bọc CRUD của `core/tasks.py`. **Sửa bug thật**: `save_task` trước đây ghi không atomic — cùng lớp bug đã sửa ở R06-T02. |
|
||||
| R07-T02 | `domain/tasks/schedule_calculator.py::ScheduleCalculator` | Tách "schedule math" (cron/interval/daily/weekly/monthly + holiday exclusion) thành pure Python, trước đây **0 test**, giờ có 14 test. |
|
||||
| R07-T03 | `infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock` | Bọc `QTimer` sau 1 interface nhỏ, inject qua `clock=`. **Đổi vị trí so với plan gốc** — xem mục 5. |
|
||||
| R07-T04 | `application/scheduling/task_application_service.py::TaskApplicationService` | Gom CRUD + luật kéo-thả Kanban (`move_to_status`). |
|
||||
| R07-T05 | `application/scheduling/ai_task_planner_service.py::AiTaskPlannerService` | Seam cho `plan_tasks`/`import_tasks`. |
|
||||
|
||||
*(R07-T06 `Co4EWorkflowService` là việc Team Nam — không đụng.)*
|
||||
|
||||
### 🔹 EPIC R08 — UI/Application Separation (4/4, phạm vi Team Hoa: T11→T14)
|
||||
|
||||
`presentation/` **chưa tồn tại** trước task này — Team Hoa tạo cấu trúc lần đầu.
|
||||
|
||||
| Task | God file gốc | Tách thành |
|
||||
| :--- | :--- | :--- |
|
||||
| R08-T11 | `ui/schedule_task_tab.py` (795 dòng) | `presentation/scheduling/{kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,ai_task_import_dialog,run_history_dialog}.py` + shell |
|
||||
| R08-T12 | `ui/folder_tab.py` (1587 dòng — lớn nhất) | `presentation/folder/{workspace_file_tree,document_preview_manager,code_editor,office_document_renderer,ai_file_editor_dialog,ai_edit_model_resolver,ai_edit_pipeline}.py` + shell |
|
||||
| R08-T13 | `ui/dashboard_tab.py` (437 dòng) | `presentation/dashboard/{token_usage_card_widget,usage_chart_widget,habits_widget}.py` + shell, `application/monitoring/dashboard_query_service.py` |
|
||||
| R08-T14 | `ui/structure_graph_view.py` (1035 dòng) | `presentation/graph/{graph_scene_items,graph_renderer,graph_messages_view,graph_qa_widget}.py` + shell |
|
||||
|
||||
*(R08-T01→T10 thuộc Team Duy/Team Nam — không đụng.)* Mỗi god-file cũ chỉ có 1-2 nơi khởi tạo thật (`app.py`, `ui/workspace_tab.py`) nên đã **sửa thẳng import site** và **xoá hẳn file `ui/*.py` cũ** thay vì giữ shim (khác `core/tools.py` ở R05, có hàng chục call site).
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến trúc sau refactor
|
||||
|
||||
```text
|
||||
presentation/ (MỚI ở R08 — Team Hoa tạo cấu trúc lần đầu)
|
||||
scheduling/ {kanban_board_widget, calendar_view_widget,
|
||||
ai_task_creator_dialog, ai_task_import_dialog,
|
||||
run_history_dialog, schedule_task_tab}.py
|
||||
folder/ {workspace_file_tree, document_preview_manager, code_editor,
|
||||
office_document_renderer, ai_file_editor_dialog,
|
||||
ai_edit_model_resolver, ai_edit_pipeline, folder_tab}.py
|
||||
dashboard/ {token_usage_card_widget, usage_chart_widget,
|
||||
habits_widget, dashboard_tab}.py
|
||||
graph/ {graph_scene_items, graph_renderer, graph_messages_view,
|
||||
graph_qa_widget, structure_graph_view}.py
|
||||
shared/ web_engine_support.py (HAS_WEB_ENGINE dùng chung)
|
||||
│
|
||||
▼
|
||||
application/ conversations/tool_policy_gateway.py ← ALLOW/CONFIRM cho mọi tool call (R05)
|
||||
workspaces/{file_workspace_service, file_preview_helpers,
|
||||
ai_edit_output, graph_index_service}.py (R06, R08)
|
||||
scheduling/{task_application_service, ai_task_planner_service}.py (R07)
|
||||
monitoring/dashboard_query_service.py (R08)
|
||||
│ (100% pure Python — check_imports.py chặn import Qt)
|
||||
▼
|
||||
domain/ tools/{tool_descriptor,tool_registry}.py ← capability + catalogue (R05)
|
||||
workspaces/workspace_session.py ← snapshot workspace bất biến (R06)
|
||||
tasks/schedule_calculator.py ← due-time/cron math thuần Python (R07)
|
||||
▲
|
||||
infrastructure/ filesystem/{file_tools,command_tools,fetch_tools,tool_context,execution_workspace}.py (R05/R06)
|
||||
mcp/mcp_source_manager.py ← lifecycle connection MCP (R05)
|
||||
persistence/json/{atomic_write,workspace_repository_impl,
|
||||
conversation_repository_impl,task_repository_impl}.py (R06/R07)
|
||||
qt/qt_scheduler_clock.py ← QTimer đằng sau 1 interface nhỏ (R07)
|
||||
```
|
||||
|
||||
**Nguyên tắc di trú xuyên suốt cả 4 EPIC (ADR-001 mục 4, tiếp nối cách Team Duy làm ở R04)**: **không viết lại engine**. `core/tools.py::execute_tool`, `core/chat_agent.py::run_cowork`, `core/tasks.py`, `core/task_scheduler.py`, `core/task_executors.py` vẫn là engine bên dưới — tầng mới chỉ sở hữu phần từng nằm rải rác/hardcode trong widget hoặc dispatcher. `pytest` xanh liên tục giữa các bước, chạy full suite sau MỖI task.
|
||||
|
||||
**Riêng ở R08**: khi 1 file bị tách vượt 400 dòng dù đã theo đúng mapping gốc, đã tách thêm file phụ theo kiểu **composition** (class phụ nhận `owner` là widget chính) thay vì service riêng — ví dụ `run_history_dialog.py`, `office_document_renderer.py`, `ai_edit_pipeline.py`, `ai_edit_model_resolver.py`, `graph_messages_view.py`. Đây là split kỹ thuật để đạt giới hạn LOC, không phải ranh giới tầng kiến trúc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bằng chứng kiểm thử
|
||||
|
||||
### Tiến trình test qua từng EPIC
|
||||
|
||||
| Mốc | Tổng pass | Ghi chú |
|
||||
| :--- | ---: | :--- |
|
||||
| Sau R05+R06 | 283 (/287, 4 fail) | +41 unit test + 2 integration test (Qt offscreen thật) |
|
||||
| Sau R07 | 328 | +45 test mới (task repo, schedule calculator, Qt clock, task/AI-planner services) |
|
||||
| Sau R08 | **377** | +49 test mới (4 widget split, mỗi cái có unit + integration Qt offscreen) |
|
||||
|
||||
**4 fail cuối cùng — cùng 1 baseline có sẵn từ trước, xuyên suốt cả 4 EPIC, không phải do Team Hoa**:
|
||||
* `tests/test_config_security.py` × 2 (EPIC R02/Team Nam — `config.py` hardcode `sandbox_pw`)
|
||||
* `tests/unit/test_routing_wiring.py` × 2 (môi trường máy này có Ollama/llama3.1 thật, khác giả định "fresh install" của test)
|
||||
|
||||
### Đối chiếu Definition of Done
|
||||
|
||||
| # | Tiêu chí | Kết quả |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Mọi file mới < 400 dòng | ✅ Lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng |
|
||||
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
|
||||
| 3 | Comment tiếng Anh giải thích lý do ở mọi khối sửa/mới | ✅ |
|
||||
| 4 | Có unit/contract/integration test, verify bằng chạy thật | ✅ +90 test mới sau R05/R06 lên tới 377; mọi widget Qt test bằng offscreen thật, không double |
|
||||
| 5 | Không hồi quy | ✅ 377/381 pass — 4 fail cùng 1 baseline có sẵn, không đổi qua 4 EPIC |
|
||||
| 6 | Ghi Start/End vào Checklist | ✅ 19 task đã tick kèm mốc thời gian thật |
|
||||
| 7 | Cổng CASAN (`run_quality_gate.py`, R10-T02) | ⚠️ Chưa viết (thuộc R10, chưa tới lượt) — Check 3 đã PASS |
|
||||
|
||||
---
|
||||
|
||||
## 5. Lỗi thật phát hiện & quyết định kiến trúc
|
||||
|
||||
### 🔴 Lỗi 1 (R05-T04) — Tool MCP/Connector chạy hoàn toàn không qua permission gate
|
||||
|
||||
`core/chat_agent.py::run_cowork` có 2 nhánh dispatch tool call: built-in đi qua gate xác nhận khi Settings bật "confirm before running commands"; nhánh `extra_tools` (mọi tool từ MCP server hoặc Connector) gọi thẳng `extra_executor(name, args)` **không qua bước xác nhận nào**. Đây không phải khác biệt thiết kế — không có ghi chú, không có toggle riêng.
|
||||
|
||||
*Sửa*: mọi `extra_tools` gắn `ToolCapability` mặc định bảo toàn (`WRITE|EXECUTE|NETWORK`), đi qua CÙNG `ToolPolicyGateway` với built-in tools. **Thay đổi hành vi người dùng sẽ thấy**: tool MCP/connector giờ hỏi xác nhận khi "confirm before running commands" bật. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
|
||||
|
||||
### 🟠 Lỗi 2 (R06-T02, R07-T01) — Ghi file không atomic ở 3 nơi
|
||||
|
||||
`core/projects.py::save_project`, `core/history.py::save_conversation/rename_conversation/set_pinned` (R06), và `core/tasks.py::save_task` (R07) đều từng dùng `path.write_text(json.dumps(...))` trần — crash giữa lúc ghi để lại file JSON hỏng, và hàm `load_*` tương ứng coi file hỏng như "không tồn tại" → **mất project/hội thoại/task âm thầm, không báo lỗi**. Cả 4 điểm ghi giờ qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng, cho cả 2 đợt sửa.
|
||||
|
||||
### 🟡 Lỗi 3 (R06-T04) — Turn chạy ngầm lưu nhầm lịch sử vào project khác
|
||||
|
||||
`ui/chat_panel.py::_persist_session` gọi `save_conversation(self.ctx.config.history_dir(), ...)`, đọc `config._project_history_dir` — field dùng chung bị `ui/workspace_tab.py::_load_current` ghi đè mỗi lần đổi project. Một turn chạy ngầm ở project A hoàn tất SAU khi user đã chuyển sang project B thì bị lưu nhầm vào lịch sử của B.
|
||||
|
||||
*Sửa*: thêm `"home_history_dir"` vào dict `ctx` mỗi turn, chụp giá trị tại lúc submit thay vì đọc sống lúc lưu. Test Qt offscreen thật: `tests/integration/test_history_dir_race.py`.
|
||||
|
||||
### 🔵 Quyết định kiến trúc (R07-T03) — `platform/qt/` đè lên module chuẩn `platform` của Python
|
||||
|
||||
Plan gốc đặt tên `platform/qt/qt_scheduler_clock.py`. Thực nghiệm trước khi viết:
|
||||
|
||||
```bash
|
||||
cd <repo_root> && python -c "import cowork_local; import platform; print(platform.system())"
|
||||
```
|
||||
|
||||
Sau khi tạo `platform/__init__.py`, lệnh trên báo lỗi `AttributeError: module 'platform' has no attribute 'system'` — bất cứ khi nào repo root nằm trực tiếp trên `sys.path` (không qua `__main__.py`'s parent-dir fixup), `import platform` phân giải nhầm vào package cục bộ. `core/windows_sandbox_vm.py`/`core/appcontainer_sandbox.py` đều `import platform`.
|
||||
|
||||
*Sửa*: chuyển sang `infrastructure/qt/qt_scheduler_clock.py` (không tạo package `platform/` mới) — đúng layer, cùng cấp `infrastructure/{filesystem,mcp,persistence,providers,telemetry}/`. Verify lại: PASS.
|
||||
|
||||
### Nối dây `FileWorkspaceService` (R06-T05 → R08-T12)
|
||||
|
||||
Báo cáo R06 để lại nợ: `FileWorkspaceService` (R06-T05) chưa có call site thật. Xác nhận lại bằng grep trước R08-T12: đúng 0 occurrence trong `ui/folder_tab.py`. Khi tách `document_preview_manager.py` (R08-T12), mọi điểm ghi text thuần (`save`, `create_new_file`, `write_content`) chuyển sang gọi `FileWorkspaceService.write_file` — dùng `WorkspaceSession.unscoped(...)` vì Folder Explorer duyệt bất kỳ thư mục nào, không giới hạn 1 project sandbox. Nhánh ghi `.pptx` (binary) vẫn giữ nguyên đường cũ.
|
||||
|
||||
**Tác dụng phụ có lợi**: `infrastructure/filesystem/file_tools.py::write_file` đã có sẵn cảnh báo cú pháp Python và tự build `.xlsx` thật từ text — 2 hành vi này **trước đây không tồn tại** trên đường ghi cũ của `folder_tab.py`, giờ được hưởng miễn phí.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
|
||||
|
||||
| Cải thiện | Ảnh hưởng |
|
||||
| :--- | :--- |
|
||||
| `McpServerConnection.is_alive()` (R05, `core/mcp_client.py`) | Cho `McpToolSourceManager` biết một connection cached đã chết để khởi động lại. |
|
||||
| `domain/tasks/schedule_calculator.py` có bộ test riêng (R07) | `core/tasks.py` tự nhận "Qt-free, unit-testable" nhưng **0 test tồn tại** cho cron/interval/holiday-exclusion trước R07-T02. Giờ 14 test. |
|
||||
| `AiEditModelResolver.routed_provider`/`.routed_model` (R08-T12, property public mới) | Cần thêm để giữ `tests/integration/test_routing_surfaces.py`'s 2 test AI-Edit routing sau khi lớp routing chuyển từ `FolderTab` sang `AiEditModelResolver` — tránh hồi quy 1 test có từ EPIC R03. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Còn nợ & cần quyết định
|
||||
|
||||
| # | Nội dung | Người quyết |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | **Xung đột quy hoạch thư mục `infrastructure/persistence/json/atomic_write.py`** (R06) với `atomic_json_file.py` do Team Nam quy hoạch ở R02-T01 — chưa có xung đột file thật, cần xác nhận hợp nhất hay giữ 2 module song song. | Team Nam |
|
||||
| 2 | **Xung đột quy hoạch thư mục `application/monitoring/`** (R08-T13, `dashboard_query_service.py`) — quy hoạch cho Team Nam ở R08-T07→T10, nhưng plan gốc lại đặt file Dashboard vào đúng thư mục này. Chưa có xung đột file thật (thư mục trống trước đó). | Team Nam |
|
||||
| 3 | **`WorkspaceRepository`/`ConversationRepository` (R06) vẫn chưa có call site sản xuất thật** — R08-T12 chỉ nối `FileWorkspaceService`, chưa đụng 2 repository kia. | Chưa có EPIC nào nhận |
|
||||
| 4 | **AI-Edit pipeline (`ai_edit_pipeline.py`) và Graph Q&A ask-flow (`graph_qa_widget.py::_ask`) chưa có test end-to-end thật** — cả 2 chạy trên `AgentWorker` (QThread) thật, và **vốn dĩ đã không có test nào trước khi refactor** (xác nhận bằng grep). | Có thể thuộc phạm vi R10 Testing Pyramid |
|
||||
| 5 | **Dev tooling chưa cập nhật đường dẫn cũ**: `tools/check_controls_alive.py`, `tools/capture_screens.py`, `tools/build_audit_page.py`, `docs/screens/*.json`, `docs/ui-audit*.html` vẫn tham chiếu `ui/schedule_task_tab.py`/`ui/folder_tab.py`/`ui/dashboard_tab.py`/`ui/structure_graph_view.py` (không còn tồn tại). Không nằm trong `tests/`, không ảnh hưởng CI. | Chưa quyết định người phụ trách |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phạm vi chưa kiểm thử
|
||||
|
||||
* **R05-T04 (gate cho MCP/connector) chưa test với MCP server thật** — dùng `ToolSpec` giả, chưa thử `core/mcp_client.py::McpServerConnection` chạy subprocess thật.
|
||||
* **`McpToolSourceManager` (R05-T05) chưa test với subprocess MCP thật** — dùng `_FakeConnection`.
|
||||
* **`presentation/folder/ai_edit_pipeline.py`** (toàn bộ luồng plan → edit → apply/discard qua `AgentWorker` streaming) — chỉ verify bằng import/construction, chưa gửi instruction qua worker thật.
|
||||
* **`presentation/graph/graph_qa_widget.py::_ask`** — tương tự, chỉ test phần không cần AgentWorker thật.
|
||||
* **`office_document_renderer.py`'s PDF/LibreOffice conversion path** — chưa xác nhận kịch bản fallback trên máy không có QtPdf/LibreOffice bằng test thật.
|
||||
* **Đã mở app thật bằng `python -c "import cowork_local.app"` sau mỗi task** để xác nhận không lỗi import — **chưa** mở app GUI thật, thao tác tay qua toàn bộ 4 màn hình đã tách để xác nhận trải nghiệm người dùng cuối.
|
||||
|
||||
---
|
||||
|
||||
## 9. Việc kế tiếp của Team Hoa
|
||||
|
||||
Toàn bộ 4 EPIC thuộc phạm vi Team Hoa (R05, R06, R07, R08 phần T11→T14) đã **hoàn tất**. Các bước còn lại không thuộc EPIC riêng của Team Hoa nữa:
|
||||
|
||||
| Việc | Điều kiện |
|
||||
| :--- | :--- |
|
||||
| Checkpoint 2 (Services & Sub-widgets, 28/08) | Cần Team Duy (R08-T01→T06) và Team Nam (R08-T07→T10) xong phần UI split của họ |
|
||||
| CASAN Check 2 (Modularity/LOC, Team Hoa chủ trì, 30/08) | `scripts/check_loc.py` chưa tồn tại (thuộc R10-T02, Team Duy) |
|
||||
| Giải quyết mục 7 #1, #2 | Khi Team Nam bắt đầu R02-T01 và R08-T07→T10 |
|
||||
| R10 (Testing, Packaging & Contributor Experience) | Team Duy chủ trì, chờ 3 team hoàn tất |
|
||||
|
||||
---
|
||||
|
||||
## 10. Lịch sử commit
|
||||
|
||||
| Commit | Nội dung |
|
||||
| :--- | :--- |
|
||||
| `ae4fe72` | feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager |
|
||||
| `cf542b7` | feat(R06): workspace session snapshot, atomic persistence, history-dir race fix |
|
||||
| `69ab8e1` | feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services |
|
||||
| `0e51356` | feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView |
|
||||
| *(gộp báo cáo)* | docs(refactor): merge Team Hoa reports R05→R08 into one; rename branch to `feature/teamhoa/r05-r08` |
|
||||
@@ -57,6 +57,74 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA (cập nhật `2026-08-21 22:57`)
|
||||
|
||||
> [!NOTE]
|
||||
> ### ✅ ĐÃ HOÀN TẤT: 10/10 task của **R05 + R06** — branch `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04)
|
||||
>
|
||||
> | EPIC | Task | Trạng thái |
|
||||
> | :--- | :--- | :--- |
|
||||
> | **R05** Tool, MCP & Connector Policy | T01 → T05 | ✅ 5/5 |
|
||||
> | **R06** Workspace, Filesystem & History Isolation | T01 → T05 | ✅ 5/5 |
|
||||
>
|
||||
> **Kiểm chứng (chạy thật):**
|
||||
> * `pytest tests/` ➔ **283 pass / 4 fail** (+41 test mới cho R05+R06, gồm 2 test Qt offscreen thật trong `tests/integration/test_history_dir_race.py`)
|
||||
> * 4 fail là **lỗi có sẵn từ trước**, không liên quan R05/R06: 2 trong `test_config_security.py` (EPIC R02, đã ghi nhận bởi Team Duy) + 2 trong `test_routing_wiring.py` (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác "fresh install").
|
||||
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
|
||||
> * Mọi file mới **< 400 dòng** (lớn nhất: `domain/tools/tool_registry.py` 125 dòng). `core/tools.py` giảm từ 566 ➔ 291 dòng.
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R08.md` (báo cáo gộp R05→R08) — kết quả từng EPIC, bằng chứng kiểm thử, 2 lỗi thật đã phát hiện (permission gate bị bỏ qua cho MCP tools, race condition lưu nhầm lịch sử), và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 🔧 TÓM TẮT R06
|
||||
> * **R06-T01**: `domain/workspaces/workspace_session.py::WorkspaceSession` — snapshot bất biến (project_id, workspace_root, sandbox_dir, allowed_paths) + `is_allowed(path)`.
|
||||
> * **R06-T02**: `infrastructure/persistence/json/{workspace_repository_impl,conversation_repository_impl}.py` bọc `core/projects.py`/`core/history.py`. **Đã sửa bug thật**: `save_project`/`save_conversation`/`rename_conversation`/`set_pinned` trước đây `path.write_text()` không atomic (crash giữa lúc ghi = file JSON hỏng, `load_project`/`load_conversation` coi file hỏng như "không tồn tại" — mất project/hội thoại âm thầm). Giờ cả 4 hàm ghi qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng.
|
||||
> * **R06-T03**: `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` — đặt tên cho quy ước `.scratch` đã có sẵn (không đổi vị trí file).
|
||||
> * **R06-T04**: Sửa race trong `ui/chat_panel.py` (không phải trực tiếp `_load_current`, xem "còn nợ" #2). `ChatPanel._persist_session` (lưu hội thoại của turn CHẠY NGẦM, không phải conversation đang xem) trước đây gọi `self.ctx.config.history_dir()` SỐNG tại thời điểm turn xong — nếu user đổi project khi turn còn chạy (`_load_current` ghi `config._project_history_dir`), turn nền lưu nhầm vào thư mục lịch sử của project MỚI. Fix: thêm `"home_history_dir"` vào dict `ctx` per-turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title`), chụp tại lúc submit. Test thật bằng Qt offscreen: `tests/integration/test_history_dir_race.py`.
|
||||
> * **R06-T05**: `application/workspaces/file_workspace_service.py::FileWorkspaceService` — cho File Explorer/AI Editor gọi `execute_tool` (list_dir/read_file/write_file/edit_file) giống agent, không tự viết lại logic.
|
||||
>
|
||||
> ### 🔧 TÓM TẮT R05
|
||||
> * **R05-T01/T02**: `core/tools.py`'s if/elif dispatcher tách thành `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` + `domain/tools/{tool_descriptor,tool_registry}.py`. `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict).
|
||||
> * **R05-T03**: `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` — thay `if gate is not None and name in ("run_command","install_package")` (chat_agent.py) và `if name in (WRITE_TOOLS|MS365_WRITE_TOOLS)` (code_agent.py) bằng một lookup capability chung. Đã verify bằng test: đúng 2 tool cũ vẫn được gate, không tool nào khác bị ảnh hưởng.
|
||||
> * **R05-T04 — ⚠️ THAY ĐỔI HÀNH VI CÓ CHỦ ĐÍCH**: trước đây MCP/connector/ext-connector tools (`core/mcp_client.py`, `core/ext_connectors.py`) chạy qua `extra_executor(name, args)` **không hề qua permission gate**. Giờ mọi `extra_tools` được gắn capability mặc định (`WRITE|EXECUTE|NETWORK`, vì MCP không có chuẩn khai báo rủi ro) và đi qua CÙNG `ToolPolicyGateway` như built-in tools. Khi Settings có "confirm before running commands" bật, tool MCP/connector giờ sẽ hỏi xác nhận — người dùng SẼ thấy thêm prompt so với trước. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
|
||||
> * **R05-T05**: `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` — tách lifecycle connection (cache/lock/start-or-skip) ra khỏi `state.py::AppContext` (trước đây inline trong `_mcp_connections`/`_conn_lock`). `AppContext` giờ chỉ gọi `self._mcp_manager.ensure/stop/stop_all`. `_ext_connections` (Connectors CAD/CAE/MS365/Other) KHÔNG thuộc phạm vi T05, vẫn giữ `_conn_lock` riêng như cũ.
|
||||
>
|
||||
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
|
||||
> 1. **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py` cho Team Nam. R06-T02 cần atomic write NGAY (bug thật, không chờ được) nên đã tạo `infrastructure/persistence/json/atomic_write.py` — tên khác, cùng thư mục, không đụng file của Team Nam. `core/projects.py`/`core/history.py` đang dùng module này trực tiếp. **Cần Team Nam xác nhận khi bắt đầu R02-T01**: nên hợp nhất `atomic_write.py` vào `atomic_json_file.py` (Team Hoa đổi 4 import) hay giữ 2 module riêng (rủi ro trôi giữa 2 cách ghi atomic).
|
||||
> 2. **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có nơi gọi thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03. Mọi call site sản xuất (`ui/workspace_tab.py`, `ui/folder_tab.py`, `state.py`, task executors) vẫn dùng trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool` — các class mới là seam cho tầng application ở EPIC sau (R07/R08), chưa nối dây.
|
||||
> 3. **R06-T04 phạm vi thực tế khác một chút so với mô tả gốc**: bug không nằm ở `ui/workspace_tab.py::_load_current` (hàm đó chỉ *set* `config._project_history_dir`, không tự đọc lại nó) mà ở `ui/chat_panel.py::_persist_session` — nơi một turn chạy ngầm đọc SỐNG giá trị đó lúc turn xong. Đã sửa đúng điểm đọc, có test Qt offscreen thật (`tests/integration/test_history_dir_race.py`), nhưng chưa đổi kiến trúc `_load_current` như plan gốc gợi ý (dùng session id thay biến toàn cục) — việc đó cần tách `ChatPanel`/`WorkspaceTab` sâu hơn, thuộc phạm vi R08 (UI/Application Separation).
|
||||
> 4. R05/R06 xong toàn bộ — Team Hoa chờ chỉ đạo cho **R07** (Scheduling & Workflow Runtime, phối hợp Team Nam) hoặc merge/review trước khi tiếp tục.
|
||||
|
||||
---
|
||||
|
||||
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA, R07 + R08 (cập nhật `2026-08-27 20:52`)
|
||||
|
||||
> [!NOTE]
|
||||
> ### ✅ ĐÃ HOÀN TẤT: 9/9 task phạm vi Team Hoa của **R07 + R08** — cùng branch `feature/teamhoa/r05-r06`
|
||||
>
|
||||
> | EPIC | Task (phạm vi Team Hoa) | Trạng thái |
|
||||
> | :--- | :--- | :--- |
|
||||
> | **R07** Scheduling & Workflow Runtime | T01 → T05 | ✅ 5/5 (T06 Co4EWorkflowService là Team Nam) |
|
||||
> | **R08** UI/Application Separation | T11 → T14 | ✅ 4/4 (T01-T10 là Team Duy/Team Nam) |
|
||||
>
|
||||
> **Kiểm chứng (chạy thật):**
|
||||
> * `pytest tests/` ➔ **377 pass / 4 fail** (+94 test mới cho R07+R08 — 328 sau R07, 377 sau R08)
|
||||
> * 4 fail là **lỗi có sẵn từ trước**, giống hệt baseline đã ghi nhận ở R05/R06 (2× `test_config_security.py` EPIC R02, 2× `test_routing_wiring.py` môi trường máy)
|
||||
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
|
||||
> * Mọi file mới **< 400 dòng** (lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng)
|
||||
> * `python -c "import cowork_local.app"` ➔ OK sau mỗi task (app khởi động được với toàn bộ import mới)
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R08.md` (báo cáo gộp R05→R08) — kết quả từng task, 1 quyết định kiến trúc đổi so với plan gốc (đã thực nghiệm xác nhận), việc "nối dây" `FileWorkspaceService` (nợ từ R06-T05), và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
|
||||
> 1. **`application/monitoring/` mới tạo ở R08-T13** (`dashboard_query_service.py`) nhưng thư mục này được quy hoạch cho Team Nam (R08-T07→T10). Chưa có xung đột file thật (thư mục trống trước đó) nhưng **cần Team Nam xác nhận** khi bắt đầu phần Monitoring của họ — xem chi tiết trong báo cáo.
|
||||
> 2. **R07-T03 đổi vị trí so với plan gốc**: `platform/qt/qt_scheduler_clock.py` ➔ `infrastructure/qt/qt_scheduler_clock.py`, sau khi xác nhận bằng thực nghiệm rằng một package `platform/` ở top-level đè lên module chuẩn `platform` của Python.
|
||||
> 3. **AI-Edit pipeline (`presentation/folder/ai_edit_pipeline.py`) và Q&A ask-flow (`presentation/graph/graph_qa_widget.py::_ask`) chưa có test end-to-end** — cả hai chạy trên `AgentWorker` (QThread) thật và **vốn đã không có test nào từ trước khi refactor** (xác nhận bằng grep). Phạm vi test của R08-T12/T14 tập trung vào phần có thể test không cần thread thật (wiring, containment, rendering) — xem mục "Phạm vi chưa kiểm thử" trong báo cáo.
|
||||
> 4. Chưa `git push` — nhánh cục bộ vẫn chưa lên được Gitea, giống tình trạng đã ghi nhận ở báo cáo R05/R06 mục 7-#1.
|
||||
|
||||
---
|
||||
|
||||
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
|
||||
|
||||
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
|
||||
@@ -135,16 +203,16 @@
|
||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Phối hợp Team Duy
|
||||
* **Mục tiêu**: Bóc tách monolithic `core/tools.py`, đưa toàn bộ Built-in tools, MCP tools và Connectors qua `ToolPolicyGateway` kiểm tra quyền phân tầng.
|
||||
|
||||
- [ ] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
|
||||
*Start: `2026-08-21 21:40` | End: `2026-08-21 21:47`*
|
||||
- [x] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
|
||||
*Start: `2026-08-21 21:47` | End: `2026-08-21 21:56`*
|
||||
- [x] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
|
||||
*Start: `2026-08-21 21:56` | End: `2026-08-21 22:04`*
|
||||
- [x] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
|
||||
*Start: `2026-08-21 22:04` | End: `2026-08-21 22:12`*
|
||||
- [x] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
|
||||
*Start: `2026-08-21 22:12` | End: `2026-08-21 22:19`*
|
||||
|
||||
---
|
||||
|
||||
@@ -152,16 +220,16 @@
|
||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì)
|
||||
* **Mục tiêu**: Xóa bỏ biến toàn cục `state.py::active_project_id`, đóng gói workspace per-turn thành `WorkspaceSession` bất biến, bảo vệ an toàn đường dẫn sandbox.
|
||||
|
||||
- [ ] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
|
||||
*Start: `2026-08-21 22:19` | End: `2026-08-21 22:24`*
|
||||
- [x] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
|
||||
*Start: `2026-08-21 22:24` | End: `2026-08-21 22:35`*
|
||||
- [x] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
|
||||
*Start: `2026-08-21 22:35` | End: `2026-08-21 22:40`*
|
||||
- [x] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
|
||||
*Start: `2026-08-21 22:40` | End: `2026-08-21 22:50`*
|
||||
- [x] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
|
||||
*Start: `2026-08-21 22:50` | End: `2026-08-21 22:57`*
|
||||
|
||||
---
|
||||
|
||||
@@ -169,18 +237,18 @@
|
||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Task Scheduling) + 🟣 **Team Nam** (Co4E Workflows)
|
||||
* **Mục tiêu**: Tách `TaskRepository` và `ScheduleCalculator` khỏi `QTimer` trong `core/task_scheduler.py#L20`; xây dựng `TaskApplicationService` và `Co4EWorkflowService`.
|
||||
|
||||
- [ ] **R07-T01 (Team Hoa)**: Tách `TaskRepository` lưu trữ JSON độc lập khỏi `core/tasks.py` ➔ `infrastructure/persistence/json/task_repository_impl.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `platform/qt/qt_scheduler_clock.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T04 (Team Hoa)**: Xây dựng `TaskApplicationService` (Pure Python) điều phối chạy, sao chép, dừng, xóa task ➔ `application/scheduling/task_application_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T05 (Team Hoa)**: Xây dựng `AiTaskPlannerService` hỗ trợ tạo / import task bằng AI ➔ `application/scheduling/ai_task_planner_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R07-T01 (Team Hoa)**: Tách `TaskRepository` lưu trữ JSON độc lập khỏi `core/tasks.py` ➔ `infrastructure/persistence/json/task_repository_impl.py`
|
||||
*Start: `2026-08-27 16:05` | End: `2026-08-27 16:14`*
|
||||
- [x] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
|
||||
*Start: `2026-08-27 16:14` | End: `2026-08-27 16:26`*
|
||||
- [x] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `infrastructure/qt/qt_scheduler_clock.py` (đổi so với plan gốc `platform/qt/...` — xem báo cáo)
|
||||
*Start: `2026-08-27 16:26` | End: `2026-08-27 16:47`*
|
||||
- [x] **R07-T04 (Team Hoa)**: Xây dựng `TaskApplicationService` (Pure Python) điều phối chạy, sao chép, dừng, xóa task ➔ `application/scheduling/task_application_service.py`
|
||||
*Start: `2026-08-27 16:47` | End: `2026-08-27 17:02`*
|
||||
- [x] **R07-T05 (Team Hoa)**: Xây dựng `AiTaskPlannerService` hỗ trợ tạo / import task bằng AI ➔ `application/scheduling/ai_task_planner_service.py`
|
||||
*Start: `2026-08-27 17:02` | End: `2026-08-27 17:14`*
|
||||
- [ ] **R07-T06 (Team Nam)**: Xây dựng `Co4EWorkflowService` (Pure Python) quản lý định nghĩa và thực thi Co4E từ `core/co4e_run_manager.py` ➔ `application/workflows/co4e_workflow_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* (ngoài phạm vi Team Hoa)
|
||||
|
||||
---
|
||||
|
||||
@@ -213,14 +281,14 @@
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
|
||||
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph):
|
||||
- [ ] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` & `graph_qa_widget.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py` (+ `run_history_dialog.py`, `schedule_task_tab.py` shell — xem báo cáo)
|
||||
*Start: `2026-08-27 17:14` | End: `2026-08-27 17:39`*
|
||||
- [x] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py` (+ `code_editor.py`, `office_document_renderer.py`, `ai_edit_model_resolver.py`, `ai_edit_pipeline.py`, `folder_tab.py` shell — xem báo cáo). Đã nối `FileWorkspaceService` (nợ từ R06-T05).
|
||||
*Start: `2026-08-27 17:39` | End: `2026-08-27 18:09`*
|
||||
- [x] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py` (+ `dashboard_tab.py` shell, `application/monitoring/dashboard_query_service.py` — xem báo cáo về ghi chú xung đột thư mục với Team Nam)
|
||||
*Start: `2026-08-27 18:09` | End: `2026-08-27 18:16`*
|
||||
- [x] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` (shell) & `graph_qa_widget.py` (+ `graph_renderer.py`, `graph_scene_items.py`, `graph_messages_view.py`, `application/workspaces/graph_index_service.py` — xem báo cáo)
|
||||
*Start: `2026-08-27 18:16` | End: `2026-08-27 20:52`*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities for schedule/due-time computation (EPIC R07)."""
|
||||
|
||||
from .schedule_calculator import ScheduleCalculator
|
||||
|
||||
__all__ = ["ScheduleCalculator"]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""ScheduleCalculator - due-time / cron / interval math for Schedule Task,
|
||||
extracted from ``core/tasks.py``'s "schedule math" section (R07-T02).
|
||||
|
||||
``core/tasks.py`` is already Qt-free (its own docstring says so), but it
|
||||
still lives under ``core/`` where nothing enforces that "pure" claim - and it
|
||||
is the ONE piece of scheduling logic ``docs/refactor/plan.md`` calls out as
|
||||
needing its own unit tests (none existed before this task; see
|
||||
``tests/unit/test_schedule_calculator.py``). Moving it to ``domain/tasks/``
|
||||
makes the purity a build-time guarantee (``scripts/check_imports.py`` fails
|
||||
the build if this file ever imports Qt, ``core``, or anything with I/O) and
|
||||
gives the date math a home that is trivially unit-testable without going
|
||||
through ``core/tasks.py``'s file-repository concerns at all.
|
||||
|
||||
Two pieces of this math are themselves implemented elsewhere in ``core/`` -
|
||||
``core/cron.py::Cron`` (5-field cron parsing) and
|
||||
``core/holiday_calendar.py::is_holiday`` (VN public holidays). Importing
|
||||
``core`` from ``domain`` is exactly what ADR-001 rule I2 forbids (domain must
|
||||
not know infrastructure/core exists), so this class takes them as
|
||||
constructor-injected callables instead of importing them - the same
|
||||
dependency-inversion shape ``application/conversations/conversation_
|
||||
application_service.py`` (R04-T03) already uses for its provider factory.
|
||||
``core/tasks.py`` wires the real ``Cron``/``is_holiday`` in; tests can inject
|
||||
plain stub functions with zero I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol
|
||||
|
||||
# Same on-disk format core/tasks.py::_TIME_FMT uses for schedule.run_at.
|
||||
# Duplicated here (not imported - that would be a domain -> core edge) since
|
||||
# it's a 1-line format string, not business logic.
|
||||
_TIME_FMT = "%Y-%m-%d %H:%M"
|
||||
|
||||
_CRON_SEARCH_GUARD = 400 # matches the guard core/tasks.py used before extraction
|
||||
|
||||
|
||||
class _CronLike(Protocol):
|
||||
"""Structural shape this class needs from a cron object - satisfied by
|
||||
``core/cron.py::Cron`` without this module importing it."""
|
||||
|
||||
def next_after(self, after: datetime) -> Optional[datetime]:
|
||||
...
|
||||
|
||||
|
||||
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, _TIME_FMT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ScheduleCalculator:
|
||||
"""Pure due-time computation for one task's ``schedule`` dict.
|
||||
|
||||
``is_holiday``: ``Callable[[date, country_code], bool]`` or ``None`` -
|
||||
when ``None``, a schedule with ``skip_holidays`` set simply never treats
|
||||
any day as a holiday (degrades gracefully instead of raising, mirroring
|
||||
how a caller who doesn't care about holidays can just not wire it up).
|
||||
|
||||
``make_cron``: ``Callable[[str], _CronLike]`` (raises on a malformed
|
||||
expression) or ``None`` - when ``None``, ``repeat_type == "cron"``
|
||||
schedules never produce a next run (same as an invalid expression today).
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
is_holiday: Optional[Callable[[Any, str], bool]] = None,
|
||||
make_cron: Optional[Callable[[str], _CronLike]] = None) -> None:
|
||||
self._is_holiday = is_holiday
|
||||
self._make_cron = make_cron
|
||||
|
||||
def is_excluded_day(self, dt: datetime, sched: Dict[str, Any]) -> bool:
|
||||
"""True when ``dt`` falls on a day this schedule must skip: a
|
||||
weekend (working_days_only) or a public holiday of the configured
|
||||
country."""
|
||||
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
return True
|
||||
if sched.get("skip_holidays") and self._is_holiday is not None:
|
||||
if self._is_holiday(dt.date(), sched.get("holiday_country", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_month(self, dt: datetime) -> datetime:
|
||||
"""Calendar-aware +1 month, clamping the day to the target month's
|
||||
length (e.g. Jan 31 + 1 month -> Feb 28/29, not an overflow error)."""
|
||||
year = dt.year + (1 if dt.month == 12 else 0)
|
||||
month = 1 if dt.month == 12 else dt.month + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
|
||||
def shift_off_excluded_days(self, dt: datetime, sched: Dict[str, Any]) -> datetime:
|
||||
"""Push ``dt`` forward one day at a time until it lands on an
|
||||
allowed day (same time of day) - used for one-time schedules set on
|
||||
a weekend/holiday."""
|
||||
guard = 0
|
||||
while self.is_excluded_day(dt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
dt += timedelta(days=1)
|
||||
guard += 1
|
||||
return dt
|
||||
|
||||
def compute_next_run(self, task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
||||
"""The next run time strictly after ``after`` for a repeating task
|
||||
(daily / weekly / monthly / cron), or ``None`` for one-shot
|
||||
schedules. Occurrences on excluded days are skipped forward."""
|
||||
sched = task.get("schedule", {})
|
||||
repeat = sched.get("repeat_type", "none")
|
||||
|
||||
if repeat == "cron":
|
||||
if self._make_cron is None:
|
||||
return None
|
||||
try:
|
||||
cron = self._make_cron(sched.get("cron_expression") or "")
|
||||
except Exception:
|
||||
# Any malformed-expression error the injected factory raises
|
||||
# (core/cron.py::CronError, or a fake's own error type in
|
||||
# tests) means "this schedule can't compute a next run" - not
|
||||
# a domain-layer crash.
|
||||
return None
|
||||
nxt = cron.next_after(after)
|
||||
guard = 0
|
||||
while nxt is not None and self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
nxt = cron.next_after(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
base = _parse_run_at(sched.get("run_at"))
|
||||
if base is None:
|
||||
return None
|
||||
if repeat == "daily":
|
||||
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
||||
elif repeat == "weekly":
|
||||
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
||||
elif repeat == "monthly":
|
||||
advance = self.add_month
|
||||
else:
|
||||
return None
|
||||
nxt = base
|
||||
while nxt <= after:
|
||||
nxt = advance(nxt)
|
||||
guard = 0
|
||||
while self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
nxt = advance(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
def due_tasks(self, tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
||||
"""Tasks that should start now: Scheduled + schedule enabled +
|
||||
run_at due."""
|
||||
due = []
|
||||
for t in tasks:
|
||||
if t.get("status") != "scheduled":
|
||||
continue
|
||||
sched = t.get("schedule", {})
|
||||
if not sched.get("enabled"):
|
||||
continue
|
||||
run_at = _parse_run_at(sched.get("run_at"))
|
||||
if run_at is not None and run_at <= now:
|
||||
due.append(t)
|
||||
return due
|
||||
|
||||
|
||||
__all__ = ["ScheduleCalculator"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Domain entities for tool risk classification and lookup (EPIC R05)."""
|
||||
|
||||
from .tool_descriptor import ToolCapability, ToolDescriptor
|
||||
from .tool_registry import (
|
||||
BUILT_IN_CAPABILITIES,
|
||||
UNKNOWN_SOURCE_CAPABILITIES,
|
||||
ToolRegistry,
|
||||
default_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ToolCapability",
|
||||
"ToolDescriptor",
|
||||
"ToolRegistry",
|
||||
"BUILT_IN_CAPABILITIES",
|
||||
"UNKNOWN_SOURCE_CAPABILITIES",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""ToolCapability / ToolDescriptor - the risk-tagged catalogue entry for one
|
||||
tool the agent loop can call (R05-T01).
|
||||
|
||||
Today a tool is just a name inside ``core/tools.py::TOOL_SPECS`` (a
|
||||
``providers.base.ToolSpec`` — name/description/JSON-schema parameters, with
|
||||
no notion of risk) plus a hand-written membership test wherever gating is
|
||||
needed: ``core/tools.py::WRITE_TOOLS``, ``core/code_agent.py``'s
|
||||
``WRITE_TOOLS | MS365_WRITE_TOOLS``, and ``core/chat_agent.py``'s literal
|
||||
``name in ("run_command", "install_package")``. Three call sites, three
|
||||
independently-maintained lists, and a new tool (or an MCP/connector tool,
|
||||
which has no list membership at all - see ``core/mcp_client.py``) is gated
|
||||
only if someone remembers to add it everywhere.
|
||||
|
||||
``ToolDescriptor`` makes the risk an attribute of the tool itself, declared
|
||||
once, so ``application/conversations/tool_policy_gateway.py`` (R05-T03) can
|
||||
decide ALLOW/CONFIRM/DENY from data instead of a growing set of literal
|
||||
tuples.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O. ``to_spec``/``from_spec`` are
|
||||
the only place this module touches something outside domain/, and that
|
||||
something (``providers.base.ToolSpec``) is itself a plain dataclass with no
|
||||
further dependencies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Flag, auto
|
||||
from typing import Any, Dict
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
|
||||
class ToolCapability(Flag):
|
||||
"""What calling a tool can do to the machine or the network.
|
||||
|
||||
A ``Flag`` (not a plain ``Enum``) because a single tool can combine risks
|
||||
- ``install_package`` writes to the environment, runs pip as a
|
||||
subprocess, AND needs network access. Composing three separate booleans
|
||||
per call site is exactly the duplication this type replaces.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
READ = auto()
|
||||
WRITE = auto()
|
||||
EXECUTE = auto()
|
||||
NETWORK = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolDescriptor:
|
||||
"""An immutable description of one callable tool.
|
||||
|
||||
Attributes:
|
||||
name: the identifier the model calls (``ToolSpec.name``).
|
||||
description: shown to the model, unchanged from ``ToolSpec``.
|
||||
parameters: JSON-Schema object for the call's arguments.
|
||||
capabilities: the risk this tool carries - see :class:`ToolCapability`.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any] = field(default_factory=dict)
|
||||
capabilities: ToolCapability = ToolCapability.NONE
|
||||
|
||||
def has(self, capability: ToolCapability) -> bool:
|
||||
"""True when this tool carries (any bit of) ``capability``."""
|
||||
return bool(self.capabilities & capability)
|
||||
|
||||
def to_spec(self) -> ToolSpec:
|
||||
"""Project back to the ``ToolSpec`` shape the model-facing catalogue
|
||||
and the provider call actually use - risk tagging is metadata the
|
||||
wire format has no room for."""
|
||||
return ToolSpec(name=self.name, description=self.description,
|
||||
parameters=self.parameters)
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: ToolSpec,
|
||||
capabilities: ToolCapability = ToolCapability.NONE) -> "ToolDescriptor":
|
||||
"""Wrap an existing ``ToolSpec`` (built-in, MCP, or connector) with a
|
||||
capability tag. The one place callers attach risk to a spec they did
|
||||
not author themselves."""
|
||||
return cls(name=spec.name, description=spec.description,
|
||||
parameters=spec.parameters, capabilities=capabilities)
|
||||
|
||||
|
||||
__all__ = ["ToolCapability", "ToolDescriptor"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""ToolRegistry - the centralised catalogue every tool source registers into
|
||||
(R05-T01).
|
||||
|
||||
Built-in file/command/fetch tools (``core/tools.py``), MCP server tools
|
||||
(``core/mcp_client.py``) and unified connectors (``core/ext_connectors.py``)
|
||||
each produce their own ``List[ToolSpec]`` today, concatenated ad-hoc by
|
||||
``core/tools.py::combine_tool_sources``. None of that concatenation carries
|
||||
risk information, which is exactly why an MCP tool call reaches
|
||||
``core/chat_agent.py`` with no ``ToolDescriptor`` to consult and skips the
|
||||
permission gate entirely (the gap R05-T04 closes).
|
||||
|
||||
``ToolRegistry`` is the one place a :class:`~domain.tools.tool_descriptor.ToolDescriptor`
|
||||
is looked up by name, so a policy gateway - or anything else that needs to ask
|
||||
"what can this tool do" - has a single source of truth instead of re-deriving
|
||||
it from a spec list.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
|
||||
from .tool_descriptor import ToolCapability, ToolDescriptor
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""An in-memory, name-keyed catalogue of :class:`ToolDescriptor`.
|
||||
|
||||
Deliberately mutable and unordered-by-name-only: a turn builds one
|
||||
registry from whichever tool sources it has (built-ins + whatever MCP
|
||||
servers/connectors are enabled), so re-registering the same name simply
|
||||
replaces the previous descriptor rather than raising - the same
|
||||
"last one wins" behaviour ``combine_tool_sources`` already has for
|
||||
duplicate tool names across sources.
|
||||
"""
|
||||
|
||||
def __init__(self, descriptors: Optional[Iterable[ToolDescriptor]] = None) -> None:
|
||||
self._by_name: Dict[str, ToolDescriptor] = {}
|
||||
for descriptor in descriptors or ():
|
||||
self.register(descriptor)
|
||||
|
||||
def register(self, descriptor: ToolDescriptor) -> None:
|
||||
self._by_name[descriptor.name] = descriptor
|
||||
|
||||
def get(self, name: str) -> Optional[ToolDescriptor]:
|
||||
return self._by_name.get(name)
|
||||
|
||||
def all(self) -> List[ToolDescriptor]:
|
||||
return list(self._by_name.values())
|
||||
|
||||
def specs(self) -> List[ToolSpec]:
|
||||
"""Every registered descriptor, projected back to ``ToolSpec`` - the
|
||||
shape the provider call and the model-facing catalogue need."""
|
||||
return [d.to_spec() for d in self._by_name.values()]
|
||||
|
||||
def capabilities_for(self, name: str) -> ToolCapability:
|
||||
"""The capability set for ``name``, or ``NONE`` for an unknown tool.
|
||||
|
||||
Returning ``NONE`` rather than raising lets a policy gateway treat an
|
||||
unregistered tool the same way as one with no declared risk - the
|
||||
gateway's DENY-on-unknown-name rule is a deliberate, separate check,
|
||||
not something this lookup should pre-empt.
|
||||
"""
|
||||
descriptor = self._by_name.get(name)
|
||||
return descriptor.capabilities if descriptor is not None else ToolCapability.NONE
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._by_name
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._by_name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Default capability map for this app's built-in tools (core/tools.py).
|
||||
# Kept here, next to the registry, rather than inside core/tools.py itself -
|
||||
# core/ is the legacy engine layer being strangled, not where new domain facts
|
||||
# should accumulate.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_CAP = ToolCapability
|
||||
BUILT_IN_CAPABILITIES: Dict[str, ToolCapability] = {
|
||||
"read_file": _CAP.READ,
|
||||
"list_dir": _CAP.READ,
|
||||
"write_file": _CAP.WRITE,
|
||||
"edit_file": _CAP.WRITE,
|
||||
"run_command": _CAP.EXECUTE,
|
||||
"install_package": _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK,
|
||||
"fetch_url": _CAP.NETWORK,
|
||||
"jira_search": _CAP.NETWORK,
|
||||
"jira_get_issue": _CAP.NETWORK,
|
||||
# Advertised by every engine but has no filesystem/process/network effect
|
||||
# of its own - it only drives the Plan panel (see core/chat_agent.py).
|
||||
"update_plan": _CAP.NONE,
|
||||
"save_file": _CAP.WRITE,
|
||||
}
|
||||
|
||||
# Tools with no standard, self-declared risk metadata (every MCP server tool,
|
||||
# every unified connector) are tagged with this conservative default - see
|
||||
# R05-T04. Better to over-gate an unknown remote tool than to silently let it
|
||||
# through as READ-only.
|
||||
UNKNOWN_SOURCE_CAPABILITIES: ToolCapability = _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK
|
||||
|
||||
|
||||
def default_registry(specs: Iterable[ToolSpec]) -> ToolRegistry:
|
||||
"""Build a registry from ``core/tools.py``'s own ``TOOL_SPECS`` (plus
|
||||
``save_file``/``update_plan``, which the engines add separately), using
|
||||
:data:`BUILT_IN_CAPABILITIES`. A spec with no entry in that map falls back
|
||||
to :data:`UNKNOWN_SOURCE_CAPABILITIES` - the same conservative default
|
||||
applied to MCP/connector tools, so a built-in nobody has classified yet
|
||||
fails safe instead of silently ungated."""
|
||||
registry = ToolRegistry()
|
||||
for spec in specs:
|
||||
capability = BUILT_IN_CAPABILITIES.get(spec.name, UNKNOWN_SOURCE_CAPABILITIES)
|
||||
registry.register(ToolDescriptor.from_spec(spec, capability))
|
||||
return registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ToolRegistry",
|
||||
"BUILT_IN_CAPABILITIES",
|
||||
"UNKNOWN_SOURCE_CAPABILITIES",
|
||||
"default_registry",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities for workspace/project isolation (EPIC R06)."""
|
||||
|
||||
from .workspace_session import WorkspaceSession
|
||||
|
||||
__all__ = ["WorkspaceSession"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""WorkspaceSession - an immutable snapshot of which project a turn belongs
|
||||
to and where it may touch the filesystem (R06-T01).
|
||||
|
||||
``state.py::AppContext.active_project_id`` is a single mutable field read by
|
||||
every background worker thread. ``ui/workspace_tab.py::_load_current`` writes
|
||||
it (and the related ``config._project_history_dir``) on the UI thread the
|
||||
moment the user switches projects - while a turn already running on a
|
||||
worker thread may read either field mid-switch and end up acting on the
|
||||
OTHER project's workspace/history for the rest of its run (the race
|
||||
R06-T04 fixes).
|
||||
|
||||
The fix, same shape as R04's ``ConversationExecutionRequest``: capture the
|
||||
workspace facts a turn needs ONCE, on the thread that knows which project is
|
||||
selected, into one frozen object. Whatever the user does to the UI afterwards,
|
||||
the turn keeps using the workspace it was handed at submit time.
|
||||
|
||||
Pure domain code: stdlib only, no Qt, no network. It does touch ``Path`` (not
|
||||
plain strings, unlike ``ConversationExecutionRequest``) because its whole job
|
||||
is path-containment checking - a snapshot with no room to answer "is this
|
||||
path mine" would not replace what ``ToolContext.resolve`` currently does
|
||||
inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceSession:
|
||||
"""Everything a turn needs to know about ITS workspace, fixed at the
|
||||
moment it was submitted.
|
||||
|
||||
Attributes:
|
||||
project_id: the project this turn belongs to (``""`` when no project
|
||||
is selected - e.g. the Code tab, which has no project concept).
|
||||
workspace_root: the project's sandbox root (``Project.workspace_dir()``).
|
||||
sandbox_dir: the ``.scratch`` subtree inside ``workspace_root`` used for
|
||||
generator/helper scripts, never a final deliverable (see
|
||||
``infrastructure/filesystem/file_tools.py::_flatten_rel``).
|
||||
allowed_paths: every root a tool call may read/write under. Almost
|
||||
always just ``(workspace_root,)``; a project with a custom
|
||||
``output_dir`` outside the managed workspace tree still resolves
|
||||
to exactly one root - the tuple exists so a future caller (e.g. a
|
||||
step scoped to a shared input folder) can widen it without a
|
||||
shape change.
|
||||
"""
|
||||
|
||||
project_id: str
|
||||
workspace_root: Path
|
||||
sandbox_dir: Path
|
||||
allowed_paths: Tuple[Path, ...] = field(default_factory=tuple)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.allowed_paths:
|
||||
object.__setattr__(self, "allowed_paths", (self.workspace_root,))
|
||||
|
||||
@classmethod
|
||||
def from_project(cls, project) -> "WorkspaceSession":
|
||||
"""Build a session from a ``core.projects.Project``. ``project`` is
|
||||
typed loosely (not imported) so this module has no dependency on
|
||||
``core/`` - the caller (``core/projects.py`` itself, or
|
||||
``application/conversations``) already has the Project in hand."""
|
||||
root = Path(project.workspace_dir())
|
||||
return cls(project_id=project.project_id, workspace_root=root,
|
||||
sandbox_dir=root / ".scratch", allowed_paths=(root,))
|
||||
|
||||
@classmethod
|
||||
def unscoped(cls, workspace_root: Path) -> "WorkspaceSession":
|
||||
"""A session for callers with no project concept (e.g. the Code tab,
|
||||
which sandboxes to a plain folder rather than a ``Project``)."""
|
||||
root = Path(workspace_root)
|
||||
return cls(project_id="", workspace_root=root, sandbox_dir=root / ".scratch")
|
||||
|
||||
def is_allowed(self, path: Path) -> bool:
|
||||
"""True when ``path`` resolves inside one of :attr:`allowed_paths`.
|
||||
|
||||
Same containment rule as ``ToolContext.resolve`` (an exact root match
|
||||
or a real descendant), but side-effect-free: it reports the answer
|
||||
instead of raising, so a caller (``FileWorkspaceService``, R06-T05)
|
||||
can decide what "not allowed" means for its own UI instead of
|
||||
catching a ``ToolError``.
|
||||
"""
|
||||
try:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
except OSError:
|
||||
return False
|
||||
for allowed in self.allowed_paths:
|
||||
root = Path(allowed).resolve()
|
||||
if resolved == root or root in resolved.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["WorkspaceSession"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Filesystem/process/network tool adapters split out of ``core/tools.py``
|
||||
(EPIC R05) and the sandbox execution context they share."""
|
||||
|
||||
from .tool_context import CancelFn, ToolContext, ToolError
|
||||
|
||||
__all__ = ["CancelFn", "ToolContext", "ToolError"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Command tools - run_command, install_package (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). These
|
||||
two are the ones today's hand-written permission gate in
|
||||
``core/chat_agent.py`` singles out by literal name
|
||||
(``name in ("run_command", "install_package")``) — R05-T03 replaces that
|
||||
tuple with a capability lookup, but the tools themselves are unchanged here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .tool_context import CancelFn, ToolContext
|
||||
|
||||
COMMAND_TIMEOUT = 120 # seconds
|
||||
|
||||
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
|
||||
".idea", ".mypy_cache", ".pytest_cache"}
|
||||
|
||||
|
||||
def _snapshot(workdir: Path) -> Dict[str, Any]:
|
||||
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
|
||||
snap: Dict[str, Any] = {}
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(str(workdir)):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
|
||||
for fn in filenames:
|
||||
full = os.path.join(dirpath, fn)
|
||||
try:
|
||||
st = os.stat(full)
|
||||
snap[full] = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
if len(snap) > 5000:
|
||||
return snap
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Optional[str]:
|
||||
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
|
||||
``ctx.sandbox``); returns its python path, or None to use the app's own."""
|
||||
if not ctx.sandbox:
|
||||
return None
|
||||
from cowork_local.core.deps import ensure_project_venv
|
||||
|
||||
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
|
||||
return str(py) if py else None
|
||||
|
||||
|
||||
def run_command(ctx: ToolContext, args: Dict[str, Any],
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Dict[str, Any]:
|
||||
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
|
||||
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
|
||||
from cowork_local.security.command_risk_classifier import classify_command
|
||||
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return {"ok": False, "output": "Empty command."}
|
||||
|
||||
# --- Security validation pipeline ---
|
||||
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
|
||||
if risk.blocked:
|
||||
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
|
||||
return {"ok": False, "output": denial}
|
||||
|
||||
# Route through SandboxManager for risk-based isolation
|
||||
mgr = SandboxManager(ExecutionConfig(
|
||||
enabled=True,
|
||||
block_network_by_default=ctx.block_network,
|
||||
is_cowork_mode=ctx.flatten_writes,
|
||||
))
|
||||
sandbox_result = mgr.run(
|
||||
command=command,
|
||||
workdir=str(ctx.workdir),
|
||||
block_network=ctx.block_network,
|
||||
timeout_sec=COMMAND_TIMEOUT,
|
||||
cancel=cancel,
|
||||
)
|
||||
# Sandbox ALWAYS executes (never double-run). Return its result directly.
|
||||
if sandbox_result.get("sandbox") == "blocked":
|
||||
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
|
||||
out = sandbox_result.get("stdout", "").strip() or "(no output)"
|
||||
err = sandbox_result.get("stderr", "")
|
||||
rc = sandbox_result.get("returncode", -1)
|
||||
if err:
|
||||
out = f"{out}\n{err}" if out else err
|
||||
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
|
||||
|
||||
|
||||
def install_package(ctx: ToolContext, args: Dict[str, Any],
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_output=None) -> Dict[str, Any]:
|
||||
from cowork_local.core.deps import pip_install
|
||||
|
||||
package = str(args.get("package", "")).strip()
|
||||
if not package:
|
||||
return {"ok": False, "output": "No package specified."}
|
||||
python = _sandbox_python(ctx, cancel, on_output)
|
||||
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
|
||||
head = f"Installed {package}." if ok else f"Could not install {package}."
|
||||
return {"ok": ok, "output": f"{head}\n{detail}"}
|
||||
|
||||
|
||||
__all__ = ["COMMAND_TIMEOUT", "run_command", "install_package", "_snapshot"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""ExecutionWorkspace - the output folder vs. the scratch folder for one
|
||||
turn, as two distinct properties instead of a name convention (R06-T03).
|
||||
|
||||
Today the ``.scratch`` subtree is a special case buried inside
|
||||
``_flatten_rel`` (``infrastructure/filesystem/file_tools.py``): a generator
|
||||
script writes there, the deliverable lands in the output root, and
|
||||
``core/chat_agent.py`` cleans ``.scratch`` up after the turn — but nothing
|
||||
NAMES "the scratch folder" as a thing; every call site re-derives
|
||||
``workdir / ".scratch"`` (or checks ``Path(rel).parts[0] == ".scratch"``) by
|
||||
hand. This class gives that convention one home.
|
||||
|
||||
It does not change WHERE files land - ``workspace_root/.scratch`` stays
|
||||
exactly what it always was. It exists so a caller (an application service,
|
||||
R06-T05's ``FileWorkspaceService``, or a future turn-cleanup step) can ask
|
||||
for "the output dir" / "the scratch dir" instead of hand-building the path
|
||||
and hoping the convention hasn't drifted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
|
||||
SCRATCH_DIRNAME = ".scratch"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionWorkspace:
|
||||
"""The two folders a turn actually writes to, derived from a
|
||||
:class:`WorkspaceSession`.
|
||||
|
||||
``output_dir`` is always the session's ``workspace_root`` itself, not a
|
||||
per-turn subfolder - Cowork's whole design is that every deliverable lands
|
||||
directly in the one configured Output folder (see
|
||||
``infrastructure/filesystem/file_tools.py::_flatten_rel``'s docstring).
|
||||
``scratch_dir`` is the SAME flat ``workspace_root/.scratch`` every turn on
|
||||
that workspace already shares today (``core/chat_agent.py``'s
|
||||
``_cleanup_cowork_intermediates`` operates on that exact path) - this
|
||||
class does not introduce per-turn namespacing that doesn't exist in the
|
||||
engine yet, only names the existing convention.
|
||||
|
||||
``turn_id`` is kept as metadata for callers that want to attribute a
|
||||
workspace to the turn that used it (logging, future per-turn scratch
|
||||
namespacing); it does not affect either path today.
|
||||
"""
|
||||
|
||||
session: WorkspaceSession
|
||||
turn_id: str
|
||||
|
||||
@property
|
||||
def output_dir(self) -> Path:
|
||||
return self.session.workspace_root
|
||||
|
||||
@property
|
||||
def scratch_dir(self) -> Path:
|
||||
return self.session.workspace_root / SCRATCH_DIRNAME
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""Create both folders if they don't exist yet. Callers that only
|
||||
need one (most do) can skip this and let ``write_file`` create parents
|
||||
on demand, same as today."""
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.scratch_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def cleanup_scratch(self) -> None:
|
||||
"""Unconditionally remove the scratch subtree.
|
||||
|
||||
Coarser than ``core/chat_agent.py::_cleanup_cowork_intermediates``,
|
||||
which rescues any real deliverable a generator script wrote INSIDE
|
||||
``.scratch`` before wiping it - that rescue logic stays there. This
|
||||
is for callers that only need "make the scratch folder go away"
|
||||
(e.g. before starting a fresh run) and know it holds nothing worth
|
||||
saving.
|
||||
"""
|
||||
shutil.rmtree(self.scratch_dir, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = ["ExecutionWorkspace", "SCRATCH_DIRNAME"]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Fetch tools - fetch_url, jira_search, jira_get_issue (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). The
|
||||
network access these three carry is exactly what the ``ToolCapability.NETWORK``
|
||||
tag added in R05-T01/domain/tools/tool_registry.py describes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
|
||||
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Fetch a URL's text content (web page / online document / SharePoint-
|
||||
OneDrive share link) via link_fetch — the same parser task-link attachments
|
||||
use. Honors the Sandbox Security Layer's "Block network" policy."""
|
||||
url = str(args.get("url", "")).strip()
|
||||
if not url:
|
||||
return {"ok": False, "output": "fetch_url: 'url' is required."}
|
||||
if not url.lower().startswith(("http://", "https://")):
|
||||
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
|
||||
if not ctx.allow_url_fetch:
|
||||
return {"ok": False,
|
||||
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
|
||||
"(\"Allow the agent to fetch URLs\").")}
|
||||
# A pasted Jira issue link on the CONNECTED Jira host is read via the
|
||||
# authenticated API (so private issues resolve, not a login page). Public
|
||||
# links / any other URL fall through to the normal fetcher below.
|
||||
from cowork_local.core import jira_tool
|
||||
if jira_tool.is_jira_issue_url(ctx.jira, url):
|
||||
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
|
||||
from cowork_local.core.link_fetch import fetch_link_preview
|
||||
|
||||
return {"ok": True, "output": fetch_link_preview(url)}
|
||||
|
||||
|
||||
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
|
||||
int(args.get("max_results", 25) or 25))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
|
||||
"output": out}
|
||||
|
||||
|
||||
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from cowork_local.core import jira_tool
|
||||
|
||||
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
|
||||
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
|
||||
"output": out}
|
||||
|
||||
|
||||
__all__ = ["fetch_url", "jira_search", "jira_get_issue"]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""File tools - read_file, list_dir, write_file, edit_file (R05-T02).
|
||||
|
||||
Moved verbatim out of ``core/tools.py``, whose ``execute_tool`` used to
|
||||
dispatch to these via a hand-written if/elif chain over every tool name it
|
||||
knew about. Splitting the built-in handlers into per-concern modules
|
||||
(this one, ``command_tools.py``, ``fetch_tools.py``) means adding a tool no
|
||||
longer means growing that one function; ``core/tools.py::execute_tool`` now
|
||||
looks the name up in a dict built from these modules instead.
|
||||
|
||||
Behavior is unchanged from before the split - this is a pure move, not a
|
||||
rewrite. Every existing characterization/contract test that exercises
|
||||
read_file/write_file/edit_file/list_dir through ``core.tools.execute_tool``
|
||||
still exercises the exact same code, just imported from here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .tool_context import ToolContext
|
||||
|
||||
MAX_READ_BYTES = 200_000
|
||||
|
||||
|
||||
def _flatten_rel(rel: str) -> str:
|
||||
"""Collapse a sub-folder path down to a bare filename so the file lands in the
|
||||
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
|
||||
|
||||
Used by the Cowork agent (flatten_writes=True) so it can never create a
|
||||
per-session / per-chat / per-task output sub-folder: every deliverable stays
|
||||
directly in the single configured Output folder."""
|
||||
parts = Path(rel).parts
|
||||
if parts and parts[0] == ".scratch":
|
||||
return rel # temporary sandbox is allowed (and cleaned up afterwards)
|
||||
return Path(rel).name or rel
|
||||
|
||||
|
||||
def _check_python_syntax(target: Path, content: str) -> str:
|
||||
"""Return a short warning if ``content`` is invalid Python, else ''.
|
||||
|
||||
Catches syntax errors the instant a .py file is written/edited — before the
|
||||
agent wastes a whole run_command round-trip just to get the same error back
|
||||
from a traceback."""
|
||||
if target.suffix.lower() not in (".py", ".pyw"):
|
||||
return ""
|
||||
try:
|
||||
ast.parse(content, filename=str(target))
|
||||
return ""
|
||||
except SyntaxError as exc:
|
||||
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
|
||||
|
||||
|
||||
def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
target = ctx.resolve(str(args.get("path", "")))
|
||||
if not target.exists():
|
||||
return {"ok": False, "output": f"File not found: {args.get('path')}"}
|
||||
data = target.read_bytes()[:MAX_READ_BYTES]
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
return {"ok": True, "output": text}
|
||||
|
||||
|
||||
def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ".") or ".")
|
||||
target = ctx.resolve(rel)
|
||||
# A missing/not-yet-created path is NOT a tool failure — report it as an
|
||||
# ordinary result so the agent can create it or pick another path and keep
|
||||
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
|
||||
# Co4E flows and could stall a step on a recoverable situation.
|
||||
if not target.exists():
|
||||
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
|
||||
if target.is_file():
|
||||
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
|
||||
entries = []
|
||||
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
marker = "/" if child.is_dir() else ""
|
||||
entries.append(f"{child.name}{marker}")
|
||||
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
|
||||
|
||||
|
||||
def write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
content = str(args.get("content", ""))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A .xlsx is a binary package — build a REAL workbook from the content
|
||||
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
|
||||
if target.suffix.lower() in (".xlsx", ".xlsm"):
|
||||
from cowork_local.core import xlsx_write
|
||||
if xlsx_write.build_xlsx_from_text(target, content):
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote spreadsheet {rel} ({target.name})."}
|
||||
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
|
||||
"write a .csv instead, or use a generator script."}
|
||||
target.write_text(content, encoding="utf-8")
|
||||
warning = _check_python_syntax(target, content)
|
||||
return {"ok": True, "path": str(target),
|
||||
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
|
||||
|
||||
|
||||
def edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet inside an existing file (precise patch edit)."""
|
||||
rel = str(args.get("path", ""))
|
||||
if ctx.flatten_writes:
|
||||
rel = _flatten_rel(rel)
|
||||
target = ctx.resolve(rel)
|
||||
if not target.exists():
|
||||
return {"ok": False,
|
||||
"output": f"File not found: {rel} — use write_file to create it."}
|
||||
old = str(args.get("old_string", ""))
|
||||
new = str(args.get("new_string", ""))
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not old:
|
||||
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
|
||||
try:
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "output": f"Could not read file: {exc}"}
|
||||
count = text.count(old)
|
||||
if count == 0:
|
||||
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
|
||||
"text to replace, including indentation/whitespace.")}
|
||||
if count > 1 and not replace_all:
|
||||
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
|
||||
"context to make it unique, or set replace_all=true.")}
|
||||
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
n = count if replace_all else 1
|
||||
warning = _check_python_syntax(target, updated)
|
||||
return {"ok": True,
|
||||
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
|
||||
|
||||
|
||||
__all__ = ["MAX_READ_BYTES", "read_file", "list_dir", "write_file", "edit_file"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""ToolContext / ToolError / CancelFn - the sandboxed execution context every
|
||||
built-in tool runs against (moved out of ``core/tools.py`` in R05-T02).
|
||||
|
||||
Kept as its own leaf module (no dependency on any sibling in this package) so
|
||||
``file_tools.py``, ``command_tools.py`` and ``fetch_tools.py`` can each import
|
||||
it without creating an import cycle back through ``core/tools.py``, which
|
||||
itself re-exports ``ToolContext``/``ToolError`` from here for the existing
|
||||
callers (``core/chat_agent.py``, ``core/code_agent.py``,
|
||||
``core/task_executors.py``) that do ``from .tools import ToolContext``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
workdir: Path
|
||||
flatten_writes: bool = False # Cowork: force every write into the workdir root
|
||||
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
|
||||
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
|
||||
# disk_mb), applied to every run_command/install_package this context runs.
|
||||
# None (default) = no limits, matching pre-existing behavior.
|
||||
resource_limits: Optional[Dict[str, float]] = None
|
||||
# Sandbox Security Layer — Settings' "Block network for agent commands"
|
||||
# (policy-level, see deps.py::network_blocked_env). False (default) =
|
||||
# unrestricted, matching pre-existing behavior.
|
||||
block_network: bool = False
|
||||
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
|
||||
# (reading a web page/share link for info is safe; running networked shell
|
||||
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
|
||||
allow_url_fetch: bool = True
|
||||
# Jira read connector config (base_url/email/api_token) — None disables the
|
||||
# jira_* tools' ability to connect. Populated from config.data["jira"].
|
||||
jira: Optional[Dict[str, Any]] = None
|
||||
|
||||
def resolve(self, rel: str) -> Path:
|
||||
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
|
||||
if rel in ("", "."):
|
||||
return self.workdir
|
||||
candidate = (self.workdir / rel).expanduser()
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError as exc:
|
||||
raise ToolError(f"Invalid path: {rel} ({exc})")
|
||||
root = self.workdir.resolve()
|
||||
if resolved != root and root not in resolved.parents:
|
||||
raise ToolError(
|
||||
f"Refused: '{rel}' is outside the working folder ({root})."
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
__all__ = ["CancelFn", "ToolError", "ToolContext"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""MCP server connection lifecycle management (EPIC R05)."""
|
||||
|
||||
from .mcp_source_manager import McpToolSourceManager
|
||||
|
||||
__all__ = ["McpToolSourceManager"]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""McpToolSourceManager - the MCP server connection lifecycle, extracted out
|
||||
of ``state.py::AppContext`` (R05-T05).
|
||||
|
||||
Today ``AppContext.build_mcp_tools`` inlines all of this: a ``_mcp_connections``
|
||||
dict, a ``_conn_lock`` guarding check-then-create against concurrent turns (a
|
||||
Cowork tab, a Co4E flow and a Scheduled Task can all call it at once), and a
|
||||
"start it, cache it, skip it on failure" loop repeated for both the
|
||||
admin-configured servers AND the built-in MS365 server
|
||||
(``_ms365_builtin_connection``). None of that logic touches Qt; it was only
|
||||
ever inline because ``AppContext`` is where the config lived.
|
||||
|
||||
This class owns the SAME cache/lock/start-or-skip behavior as a standalone,
|
||||
directly testable object — ``AppContext`` becomes a thin caller (one instance
|
||||
per app, same as it holds one ``RoutingApplicationService``).
|
||||
|
||||
Pure Python: no Qt. It DOES touch the network/filesystem via
|
||||
``core.mcp_client.McpServerConnection`` (a subprocess + asyncio loop), which is
|
||||
exactly what makes it infrastructure rather than domain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from cowork_local.core.mcp_client import McpServerConnection
|
||||
|
||||
|
||||
class McpToolSourceManager:
|
||||
"""Caches and supervises one :class:`McpServerConnection` per server name.
|
||||
|
||||
``connection_factory`` defaults to ``McpServerConnection`` itself; tests
|
||||
substitute a fake so no real subprocess is spawned (see
|
||||
``tests/unit/test_mcp_source_manager.py``).
|
||||
"""
|
||||
|
||||
def __init__(self, connection_factory=McpServerConnection) -> None:
|
||||
self._connections: Dict[str, McpServerConnection] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._connection_factory = connection_factory
|
||||
|
||||
def ensure(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
|
||||
"""Return a live connection for ``name``, starting one if there is
|
||||
none cached or the cached one's subprocess has died.
|
||||
|
||||
Serialized under one lock so two turns racing to build their tool
|
||||
list at the same moment share one subprocess per server instead of
|
||||
each spawning their own (the bug this replaces:
|
||||
``AppContext._conn_lock``'s original docstring). Returns ``None`` -
|
||||
never raises - when the server fails to start, matching the existing
|
||||
"one broken server must not block the turn" behavior.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self._connections.get(name)
|
||||
if existing is not None and existing.is_alive():
|
||||
return existing
|
||||
if existing is not None:
|
||||
self._connections.pop(name, None)
|
||||
connection = self._connection_factory(name, command, args or [], env)
|
||||
try:
|
||||
connection.start()
|
||||
except Exception: # noqa: BLE001 - one broken server must not block the turn
|
||||
return None
|
||||
self._connections[name] = connection
|
||||
return connection
|
||||
|
||||
def get(self, name: str) -> Optional[McpServerConnection]:
|
||||
"""The cached connection for ``name``, without starting one."""
|
||||
return self._connections.get(name)
|
||||
|
||||
def is_alive(self, name: str) -> bool:
|
||||
connection = self._connections.get(name)
|
||||
return connection is not None and connection.is_alive()
|
||||
|
||||
def restart(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
|
||||
"""Force a fresh connection for ``name`` even if the cached one still
|
||||
looks alive - for a server the caller knows is misbehaving."""
|
||||
with self._lock:
|
||||
self._connections.pop(name, None)
|
||||
return self.ensure(name, command, args, env)
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
"""Stop and forget one connection - used when a server becomes
|
||||
unavailable by configuration (e.g. MS365 signed out) rather than by
|
||||
crashing."""
|
||||
with self._lock:
|
||||
connection = self._connections.pop(name, None)
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.stop()
|
||||
except Exception: # noqa: BLE001 - shutdown must never raise into the caller
|
||||
pass
|
||||
|
||||
def active(self) -> List[McpServerConnection]:
|
||||
"""Every currently cached connection - what
|
||||
``core/mcp_client.py::build_mcp_tools`` merges tool specs from."""
|
||||
return list(self._connections.values())
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Terminate every connection's subprocess - called on app shutdown
|
||||
so none of them linger as orphan processes."""
|
||||
with self._lock:
|
||||
connections = list(self._connections.values())
|
||||
self._connections.clear()
|
||||
for connection in connections:
|
||||
try:
|
||||
connection.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["McpToolSourceManager"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistence adapters (EPIC R02/R06)."""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""JSON-file persistence adapters: crash-safe writes and the workspace/
|
||||
conversation/task repositories built on them (EPIC R06, R07)."""
|
||||
|
||||
from .atomic_write import write_json
|
||||
from .conversation_repository_impl import ConversationRepository
|
||||
from .task_repository_impl import TaskRepository
|
||||
from .workspace_repository_impl import WorkspaceRepository
|
||||
|
||||
__all__ = ["write_json", "WorkspaceRepository", "ConversationRepository", "TaskRepository"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""write_json - crash-safe JSON writes (R06-T02).
|
||||
|
||||
``core/projects.py::save_project`` and ``core/history.py``'s
|
||||
``save_conversation``/``rename_conversation``/``set_pinned`` all do a plain
|
||||
``path.write_text(json.dumps(...))`` today. That is two syscalls with a gap in
|
||||
between: a crash, a killed process, or a full disk between the truncate and
|
||||
the write leaves a half-written, unparseable JSON file - the NEXT read of
|
||||
that project/conversation then fails outright (``load_project`` /
|
||||
``load_conversation`` already treat a parse error as "missing", so this isn't
|
||||
even a loud failure - a project can silently vanish).
|
||||
|
||||
``write_json`` fixes this the standard way: write the full content to a
|
||||
temporary file in the SAME directory (so the following replace is on one
|
||||
filesystem, not crossing a mount point), then atomically rename it over the
|
||||
target. Either the old file is still there, or the new one is fully there -
|
||||
never a partial one.
|
||||
|
||||
Transitional note: EPIC R02 (Team Nam, ``docs/refactor/Refactoring_Checklist.md``
|
||||
R02-T01) plans a shared ``infrastructure/persistence/json/atomic_json_file.py``
|
||||
for the SAME purpose across the whole app (config, secrets, ...). This module
|
||||
is deliberately named differently and scoped to R06's two repositories only,
|
||||
so the two EPICs don't edit the same file in parallel; once R02-T01 lands,
|
||||
``WorkspaceRepository``/``ConversationRepository`` should switch to it and
|
||||
this module can go away.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
"""Serialize ``data`` as indented UTF-8 JSON and write it to ``path``
|
||||
atomically. Creates parent directories if needed."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
__all__ = ["write_json"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""ConversationRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/history.py`` (R06-T02). Same rationale as
|
||||
``workspace_repository_impl.py``: the module-level functions in
|
||||
``core/history.py`` are still what production code calls (they now write
|
||||
atomically themselves), this class is the seam for application-layer code
|
||||
that wants an object instead of a directory-parameterised function.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cowork_local.config import HISTORY_DIR
|
||||
from cowork_local.core.history import (
|
||||
delete_conversation,
|
||||
list_conversations,
|
||||
load_conversation,
|
||||
new_session_id,
|
||||
rename_conversation,
|
||||
save_conversation,
|
||||
set_pinned,
|
||||
)
|
||||
|
||||
|
||||
class ConversationRepository:
|
||||
"""CRUD + search over conversation JSON files, scoped to one
|
||||
``directory`` (defaults to the app's real ``HISTORY_DIR``)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
self._directory = Path(directory) if directory is not None else HISTORY_DIR
|
||||
|
||||
def new_session_id(self) -> str:
|
||||
return new_session_id()
|
||||
|
||||
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
|
||||
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
|
||||
|
||||
def load(self, path: Path) -> Dict[str, Any]:
|
||||
return load_conversation(path)
|
||||
|
||||
def list(self, query: str = "") -> List[Dict[str, Any]]:
|
||||
return list_conversations(self._directory, query)
|
||||
|
||||
def delete(self, path: Path) -> None:
|
||||
delete_conversation(path)
|
||||
|
||||
def rename(self, path: Path, new_title: str) -> None:
|
||||
rename_conversation(path, new_title)
|
||||
|
||||
def set_pinned(self, path: Path, pinned: bool) -> None:
|
||||
set_pinned(path, pinned)
|
||||
|
||||
|
||||
__all__ = ["ConversationRepository"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""TaskRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/tasks.py`` (R07-T01).
|
||||
|
||||
``core/tasks.py``'s module-level functions (``list_tasks``, ``load_task``,
|
||||
``save_task``, ``delete_task``, ``new_task``, ``duplicate_task``) are still
|
||||
what every existing call site (``core/task_scheduler.py``,
|
||||
``core/task_executors.py``, ``ui/schedule_task_tab.py``) uses, and stay that
|
||||
way - ``save_task`` now writes through :func:`atomic_write.write_json`
|
||||
itself (R07-T01, same class of durability fix already applied to
|
||||
``core/projects.py``/``core/history.py`` at R06-T02), so the fix applies
|
||||
whether or not a caller ever touches this class.
|
||||
|
||||
This repository exists for the application layer
|
||||
(``application/scheduling``, R07-T04) to depend on an interface instead of
|
||||
reaching into ``core/`` directly. It is a thin pass-through today, not a
|
||||
re-implementation: same on-disk format, same directory, same functions
|
||||
underneath.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cowork_local.core.tasks import (
|
||||
TASKS_DIR,
|
||||
delete_task,
|
||||
duplicate_task,
|
||||
list_tasks,
|
||||
load_task,
|
||||
new_task,
|
||||
save_task,
|
||||
)
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
"""CRUD over task dicts (see ``core/tasks.py::DEFAULT_TASK`` for shape),
|
||||
scoped to one ``directory`` (defaults to the app's real ``TASKS_DIR``;
|
||||
tests pass a ``tmp_path`` so nothing touches the user's real config
|
||||
folder)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
self._directory = directory or TASKS_DIR
|
||||
|
||||
def list(self) -> List[Dict[str, Any]]:
|
||||
return list_tasks(self._directory)
|
||||
|
||||
def get(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
return load_task(task_id, self._directory)
|
||||
|
||||
def save(self, task: Dict[str, Any]) -> Path:
|
||||
return save_task(task, self._directory)
|
||||
|
||||
def create(self, title: str = "", **overrides: Any) -> Dict[str, Any]:
|
||||
return new_task(title, **overrides)
|
||||
|
||||
def duplicate(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return duplicate_task(task)
|
||||
|
||||
def delete(self, task_id: str) -> None:
|
||||
delete_task(task_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["TaskRepository"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""WorkspaceRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/projects.py`` (R06-T02).
|
||||
|
||||
``core/projects.py``'s module-level functions (``list_projects``,
|
||||
``load_project``, ``save_project``, ``new_project``, ``delete_project``) are
|
||||
still what every existing call site (``ui/workspace_tab.py``, ``state.py``,
|
||||
task executors) uses, and stay that way - they now write through
|
||||
:func:`atomic_write.write_json` themselves, so the durability fix applies
|
||||
whether or not a caller ever touches this class.
|
||||
|
||||
This repository exists for the application layer (``application/workspaces``,
|
||||
R06-T05) to depend on an interface instead of reaching into ``core/`` -
|
||||
useful once code above ``core/`` starts being written against
|
||||
``domain``/``application`` seams instead of the legacy module functions. It
|
||||
is a thin pass-through today, not a re-implementation: same on-disk format,
|
||||
same directory, same functions underneath.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from cowork_local.core.projects import (
|
||||
PROJECTS_DIR,
|
||||
Project,
|
||||
delete_project,
|
||||
list_projects,
|
||||
load_project,
|
||||
new_project,
|
||||
save_project,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceRepository:
|
||||
"""CRUD over :class:`~cowork_local.core.projects.Project`, scoped to one
|
||||
``directory`` (defaults to the app's real ``PROJECTS_DIR``; tests pass a
|
||||
``tmp_path`` so nothing touches the user's real config folder)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
self._directory = directory or PROJECTS_DIR
|
||||
|
||||
def list(self) -> List[Project]:
|
||||
return list_projects(self._directory)
|
||||
|
||||
def get(self, project_id: str) -> Optional[Project]:
|
||||
return load_project(project_id, self._directory)
|
||||
|
||||
def save(self, project: Project) -> Path:
|
||||
return save_project(project, self._directory)
|
||||
|
||||
def create(self, name: str, description: str = "", instructions: str = "",
|
||||
output_dir: str = "") -> Project:
|
||||
return new_project(name, description, instructions, output_dir, self._directory)
|
||||
|
||||
def delete(self, project_id: str) -> bool:
|
||||
return delete_project(project_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceRepository"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Qt-backed adapters for pure interfaces used elsewhere in the app (EPIC R07).
|
||||
|
||||
Note: the original plan (``docs/refactor/Feature_Architecture_Proposal.md``)
|
||||
placed this adapter at a new top-level ``platform/qt/`` package. That name
|
||||
was dropped after it was shown to actually shadow the stdlib ``platform``
|
||||
module (used by ``core/windows_sandbox_vm.py``/``core/appcontainer_sandbox.
|
||||
py``) whenever the repo root ends up on ``sys.path`` directly - e.g. running
|
||||
``python -c "..."`` (or any script) with the repo root as the working
|
||||
directory, which resolves a bare ``import platform`` to this package instead
|
||||
of the standard library one. ``infrastructure/`` already exists as a layer
|
||||
for exactly this kind of toolkit-specific implementation
|
||||
(``infrastructure/filesystem/``, ``infrastructure/mcp/``, ...), so the
|
||||
adapter lives here instead - same content, safer location.
|
||||
"""
|
||||
|
||||
from .qt_scheduler_clock import QtSchedulerClock
|
||||
|
||||
__all__ = ["QtSchedulerClock"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""QtSchedulerClock - the ``QTimer``-backed periodic ticker `TaskScheduler`
|
||||
needs, pulled out from ``core/task_scheduler.py`` into its own adapter
|
||||
(R07-T03).
|
||||
|
||||
``core/task_scheduler.py::TaskScheduler`` is the only file in the scheduling
|
||||
stack that imports Qt at all (confirmed by grep — ``core/tasks.py`` and
|
||||
``core/task_executors.py`` are Qt-free). Everything it needs Qt FOR is small
|
||||
and mechanical: an interval timer that calls back into ``tick()`` every
|
||||
``TICK_MS``, plus, during ``stop()``, a way to pump the event loop so a
|
||||
worker thread's queued ``finished_ok``/``failed`` signal still gets delivered
|
||||
while draining running tasks (see the long comment on ``TaskScheduler.stop()``
|
||||
for why that pump matters).
|
||||
|
||||
Wrapping exactly that surface — ``start(interval_ms, callback)``, ``stop()``,
|
||||
``pump()`` — behind :class:`QtSchedulerClock` lets ``TaskScheduler`` take a
|
||||
clock as a constructor parameter instead of constructing a ``QTimer``
|
||||
itself. Production wiring is unchanged (``TaskScheduler`` defaults to a real
|
||||
``QtSchedulerClock`` when no clock is passed); tests can inject
|
||||
``tests/fakes/fake_clock.py::FakeClock`` to control ticks by hand with no Qt
|
||||
event loop running at all.
|
||||
|
||||
See ``infrastructure/qt/__init__.py`` for why this lives under
|
||||
``infrastructure/qt/`` and not the ``platform/qt/`` path the original plan
|
||||
named.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QTimer
|
||||
|
||||
|
||||
class QtSchedulerClock:
|
||||
"""Owns one ``QTimer``. Not itself a ``QObject`` subclass — it OWNS a
|
||||
``QObject``-parented timer instead of inheriting from one, so callers
|
||||
(like ``FakeClock`` in tests) can satisfy the same duck-typed interface
|
||||
without any Qt base class at all."""
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
# Parented so the timer is torn down with its owner instead of
|
||||
# outliving it — the same lifetime QTimer(self) gave it inside
|
||||
# TaskScheduler before this extraction.
|
||||
self._timer = QTimer(parent)
|
||||
self._timer.timeout.connect(self._on_timeout)
|
||||
self._callback: Optional[Callable[[], None]] = None
|
||||
|
||||
def _on_timeout(self) -> None:
|
||||
if self._callback is not None:
|
||||
self._callback()
|
||||
|
||||
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
|
||||
"""Arm and start the timer. Calling this again while already
|
||||
running re-arms it with the new interval/callback (matches
|
||||
``QTimer.start()``'s own restart-on-repeat-call behaviour)."""
|
||||
self._callback = callback
|
||||
self._timer.setInterval(interval_ms)
|
||||
self._timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._timer.stop()
|
||||
|
||||
def pump(self) -> None:
|
||||
"""Process one batch of pending Qt events — used by
|
||||
``TaskScheduler.stop()``'s bounded drain loop so a worker thread's
|
||||
queued completion signal can still be delivered while we wait for it
|
||||
to exit."""
|
||||
QCoreApplication.processEvents()
|
||||
|
||||
|
||||
__all__ = ["QtSchedulerClock"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Presentation layer: Qt widgets, one screen/concern per file, assembled
|
||||
into thin shell containers (EPIC R08).
|
||||
|
||||
Nothing under here is imported by ``domain/`` or ``application/``
|
||||
(``scripts/check_imports.py`` rule I3) — data flows the other way, through
|
||||
application services these widgets call.
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Dashboard screen, split into single-responsibility widgets (R08-T13):
|
||||
``token_usage_card_widget``, ``usage_chart_widget``, ``habits_widget``,
|
||||
assembled by the ``dashboard_tab`` shell."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""DashboardTab shell (R08-T13) — assembles
|
||||
``token_usage_card_widget.py::TokenUsageCardWidget``,
|
||||
``usage_chart_widget.py::UsageChartWidget`` and
|
||||
``habits_widget.py::HabitsWidget`` behind the scroll area / header / 30s
|
||||
auto-refresh timer that used to be inline in
|
||||
``ui/dashboard_tab.py::DashboardTab.__init__`` (lines 40-193 of the original
|
||||
437-line file).
|
||||
|
||||
The one ``DashboardQueryService`` (R08-T13) instance is built here and
|
||||
shared by all three children so pricing/currency stay consistent across the
|
||||
whole screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._query = DashboardQueryService(ctx)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
self.token_cards = TokenUsageCardWidget(ctx, self._query)
|
||||
root.addWidget(self.token_cards)
|
||||
|
||||
self.chart = UsageChartWidget(ctx, self._query)
|
||||
self.chart.period_changed.connect(self.refresh)
|
||||
self.chart.currency_changed.connect(self.refresh)
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.habits = HabitsWidget(ctx, self._query)
|
||||
self.habits.status_message.connect(self.status_message.emit)
|
||||
root.addWidget(self.habits, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.token_cards.retranslate()
|
||||
self.chart.retranslate()
|
||||
self.habits.retranslate()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, *_a) -> None:
|
||||
start, end = self.chart.period_range()
|
||||
self.token_cards.refresh(start, end)
|
||||
self.chart.refresh()
|
||||
self.habits.refresh(start, end)
|
||||
|
||||
|
||||
__all__ = ["DashboardTab"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""HabitsWidget — the usage-habits summary + AI recommendations panel of the
|
||||
Dashboard (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``,
|
||||
lines 154-184/348-372/376-438 of the original 437-line file: the habits/AI
|
||||
layout, ``refresh()``'s habits-HTML section, ``_apply_saving_strategy``,
|
||||
``_ai_analyze``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class HabitsWidget(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._period_range = (None, None) # set on each refresh(); _ai_analyze reuses it
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy — only after the user
|
||||
# clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
self._period_range = (start, end)
|
||||
summary = self._query.summary(start, end)
|
||||
s, events = summary["stats"], summary["events"]
|
||||
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user
|
||||
approves: turn on auto-compress and compress earlier (lower
|
||||
threshold) + compress content before sending it to the agent."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the
|
||||
active provider and show habit feedback + token-saving
|
||||
recommendations."""
|
||||
if self._ai_worker is not None:
|
||||
return
|
||||
start, end = self._period_range
|
||||
if start is None:
|
||||
return
|
||||
summary = self._query.summary(start, end)
|
||||
if not summary["events"]:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
stats = summary["stats"]
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(stats, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True)
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
|
||||
|
||||
__all__ = ["HabitsWidget"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""TokenUsageCardWidget — the stat-card grid + budget card of the Dashboard
|
||||
(R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
116-141/226-254/337-346 of the original 437-line file: the card grid layout,
|
||||
``_apply_budget``, ``_refresh_budget``, and ``refresh()``'s card-filling
|
||||
section).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from PySide6.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import BudgetCard, StatCard, fmt_tokens
|
||||
|
||||
|
||||
class TokenUsageCardWidget(QWidget):
|
||||
"""Cost is the headline this screen exists for, so it gets a card twice
|
||||
the height of the rest instead of being the fifth of five identical
|
||||
tiles — with six equal cards nothing said which number mattered."""
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
|
||||
cards_grid = QGridLayout(self)
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = StatCard()
|
||||
self.card_in = StatCard()
|
||||
self.card_out = StatCard()
|
||||
self.card_cache = StatCard()
|
||||
self.card_cost = StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures
|
||||
# fill a 2x2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
summary = self._query.summary(start, end)
|
||||
s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"]
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(summary["total_cost"], pricing, digits=2), est_note)
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
self._query.set_budget(self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
pricing = self._query.pricing()
|
||||
status = self._query.budget_status()
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency)
|
||||
# — only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
|
||||
__all__ = ["TokenUsageCardWidget"]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""UsageChartWidget — the period pager + granularity/metric/currency
|
||||
controls + spline chart of the Dashboard (R08-T13, extracted from
|
||||
``ui/dashboard_tab.py::DashboardTab``, lines 53-114/143-152/201-207/
|
||||
263-323 of the original 437-line file).
|
||||
|
||||
Owns the period SELECTOR (granularity + prev/next offset) that the whole
|
||||
screen follows — ``token_usage_card_widget.py`` and ``habits_widget.py``
|
||||
read :meth:`period_range`/:meth:`granularity` rather than keeping their own
|
||||
copy, and the shell re-refreshes them on :attr:`period_changed`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.spline_chart import SplineChart
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class UsageChartWidget(QWidget):
|
||||
period_changed = Signal() # granularity or offset changed — re-run every widget
|
||||
currency_changed = Signal() # display currency changed — same, cost text depends on it
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
controls.addWidget(self.chart_prev_btn)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
controls.addWidget(self.gran_combo)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self.refresh)
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
# Display-currency picker — both Dashboard and Monitoring read/write
|
||||
# the same usage.currency config key, so changing it here updates
|
||||
# cost text everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
controls.addWidget(self.currency_lbl)
|
||||
self.currency_combo = QComboBox()
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
|
||||
# ---- public: the period selector every other widget follows ------------- #
|
||||
def granularity(self) -> str:
|
||||
return self.gran_combo.currentData() or "week"
|
||||
|
||||
@property
|
||||
def chart_offset(self) -> int:
|
||||
return self._chart_offset
|
||||
|
||||
def period_range(self) -> Tuple:
|
||||
return self._query.period_range(self.granularity(), self._chart_offset)
|
||||
|
||||
# ---- navigation ------------------------------------------------------------ #
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
self._chart_offset -= 1
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.period_changed.emit()
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.currency_changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
# ---- rendering --------------------------------------------------------------- #
|
||||
def refresh(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK -> 7 days (Mon-Sun)
|
||||
- MONTH -> weeks W1..Wn - YEAR -> 12 months. A dashed line marks the
|
||||
previous same-granularity period's average per point with the %
|
||||
change of the totals."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
gran = self.granularity()
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
pts = self._query.chart_series(gran, self._chart_offset, metric)
|
||||
pricing = self._query.pricing()
|
||||
mi = 0 if metric == "tokens" else 1
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision
|
||||
# overflowed it, clipping/obscuring the amount.
|
||||
fmt = fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
cur = self._query.period_totals(gran, self._chart_offset)
|
||||
prev = self._query.period_totals(gran, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(pts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(self._query.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
|
||||
__all__ = ["UsageChartWidget"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Folder Explorer screen, split into single-responsibility widgets
|
||||
(R08-T12): ``workspace_file_tree``, ``document_preview_manager``,
|
||||
``ai_edit_model_resolver``, ``ai_file_editor_dialog``, assembled by the
|
||||
``folder_tab`` shell."""
|
||||
@@ -0,0 +1,248 @@
|
||||
"""AiEditModelResolver — model picker + Auto Model Routing + image-model
|
||||
discovery for the AI-Edit panel (R08-T12, extracted from
|
||||
``ui/folder_tab.py::FolderTab``, lines 802-1036/911-961 of the original
|
||||
1587-line file: ``refresh_ai_models``, ``_scan_all_image_models``,
|
||||
``_ai_provider``, ``_ai_apply_routing``, ``_confirm_routing_switch``,
|
||||
``_ai_image_model``, ``_maybe_suggest_image_model``,
|
||||
``_suggest_cross_provider_image``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` — this is genuinely a
|
||||
distinct concern (which provider/model answers THIS run) from the panel's
|
||||
send/plan/edit orchestration, and splitting it out is also what keeps
|
||||
``ai_file_editor_dialog.py`` under the 400-line cap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class AiEditModelResolver:
|
||||
"""Owns the AI-edit model combo's contents and every "which provider/
|
||||
model should THIS run use" decision — independent of the Cowork/Settings
|
||||
agent, exactly like the original panel's own picker was.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
model_combo: the ``QComboBox`` populated by :meth:`refresh`.
|
||||
on_status: ``(text) -> None`` — posts a status line into the AI
|
||||
chat (production passes ``ai_chat.add_status``).
|
||||
confirm_switch: ``(self, decision, timeout) -> bool`` — the Qt
|
||||
confirm dialog for Manual routing mode (kept as a callback so
|
||||
this class never imports a dialog itself).
|
||||
"""
|
||||
|
||||
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
|
||||
self.ctx = ctx
|
||||
self._combo = model_combo
|
||||
self._on_status = on_status
|
||||
self._confirm_switch = confirm_switch
|
||||
self._models: List[str] = []
|
||||
self._models_provider = ""
|
||||
self._all_image_models: List[Tuple[str, str]] = [] # [(provider_key, model)]
|
||||
self._img_scan_worker = None
|
||||
self._pending_img_suggest = False
|
||||
self._routed_provider: Optional[str] = None
|
||||
self._routed_model: Optional[str] = None
|
||||
|
||||
@property
|
||||
def models(self) -> List[str]:
|
||||
return self._models
|
||||
|
||||
@property
|
||||
def models_provider(self) -> str:
|
||||
return self._models_provider
|
||||
|
||||
@property
|
||||
def routed_provider(self) -> Optional[str]:
|
||||
"""The provider :meth:`apply_routing` switched to for the current
|
||||
run, or ``None`` when it didn't switch (routing off/declined)."""
|
||||
return self._routed_provider
|
||||
|
||||
@property
|
||||
def routed_model(self) -> Optional[str]:
|
||||
return self._routed_model
|
||||
|
||||
def should_refresh(self) -> bool:
|
||||
"""True on first open, or when the active provider changed since
|
||||
the model list was last loaded — a stale list would resolve a pick
|
||||
to the wrong/default model at the new endpoint."""
|
||||
return self._combo.count() <= 1 or self._models_provider != self.ctx.config.active_provider
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Fetch the active provider's model list (background) into the
|
||||
picker. Also proactively scans ALL providers for image-capable
|
||||
models so a suggestion is ready the moment one is needed."""
|
||||
name = self.ctx.config.active_provider
|
||||
setting_model = self.ctx.config.provider_conf(name).get("model", "")
|
||||
|
||||
def job(worker):
|
||||
prov = self.ctx.build_provider_for(name)
|
||||
try:
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001
|
||||
models = []
|
||||
return {"models": models}
|
||||
|
||||
def done(res):
|
||||
fetched = list(res.get("models", []))
|
||||
# Always offer the Settings-configured model as an explicit
|
||||
# choice, even when the provider can't list models.
|
||||
self._models = list(dict.fromkeys(
|
||||
([setting_model] if setting_model else []) + [m for m in fetched if m]))
|
||||
self._models_provider = name
|
||||
cur = self._combo.currentData()
|
||||
self._combo.blockSignals(True)
|
||||
self._combo.clear()
|
||||
self._combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
for m in self._models:
|
||||
self._combo.addItem(m, m)
|
||||
idx = self._combo.findData(cur)
|
||||
self._combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self._combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._models_worker = w
|
||||
w.start()
|
||||
self._scan_all_image_models()
|
||||
|
||||
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
|
||||
if self._img_scan_worker is not None:
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
return
|
||||
providers = dict(self.ctx.config.data.get("providers", {}))
|
||||
candidates = [k for k, c in providers.items()
|
||||
if (c.get("base_url") or c.get("api_key"))]
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import image_gen
|
||||
found = []
|
||||
for key in candidates:
|
||||
try:
|
||||
prov = self.ctx.build_provider_for(key)
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001 - a broken provider must not block the scan
|
||||
models = []
|
||||
for m in models:
|
||||
if image_gen.looks_like_image_model(m):
|
||||
found.append((key, m))
|
||||
return {"found": found}
|
||||
|
||||
def done(res):
|
||||
self._img_scan_worker = None
|
||||
self._all_image_models = list(res.get("found", []))
|
||||
if self._pending_img_suggest:
|
||||
self._pending_img_suggest = False
|
||||
self._suggest_cross_provider_image()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(self._on_image_scan_failed)
|
||||
self._img_scan_worker = w
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
w.start()
|
||||
|
||||
def _on_image_scan_failed(self, _err) -> None:
|
||||
self._img_scan_worker = None
|
||||
|
||||
def provider(self) -> Any:
|
||||
"""Build a provider using the model chosen in the picker ('(auto)'
|
||||
-> the active provider's default), or an Auto/Manual routing
|
||||
override set by :meth:`apply_routing` for the current run."""
|
||||
if self._routed_provider or self._routed_model:
|
||||
provider = self._routed_provider or self.ctx.config.active_provider
|
||||
return self.ctx.build_provider_for(provider, self._routed_model or None)
|
||||
model = self._combo.currentData()
|
||||
return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None)
|
||||
|
||||
def apply_routing(self, instruction: str) -> None:
|
||||
"""Auto Model Routing for the AI-Edit surface (always a CODING
|
||||
task). Sets the routing override :meth:`provider` honours."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self._combo.currentData()
|
||||
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
task_type=TaskType.CODING, confirm=self._confirm_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return
|
||||
self._routed_provider, self._routed_model = decision.target()
|
||||
self._on_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
|
||||
_IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram",
|
||||
"ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト")
|
||||
|
||||
def maybe_suggest_image_model(self, instruction: str) -> None:
|
||||
"""If the request looks image-related, suggest a suitable image
|
||||
model BEFORE running — active provider first, then ALL providers."""
|
||||
from cowork_local.core import image_gen
|
||||
low = (instruction or "").lower()
|
||||
if not any(w in low for w in self._IMAGE_WORDS):
|
||||
return
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
self._on_status(tr("folder.ai_image_suggest", model=local))
|
||||
return
|
||||
if self._all_image_models:
|
||||
self._suggest_cross_provider_image()
|
||||
elif self._img_scan_worker is not None:
|
||||
self._pending_img_suggest = True
|
||||
else:
|
||||
self._scan_all_image_models(then_suggest=True)
|
||||
|
||||
def _suggest_cross_provider_image(self) -> None:
|
||||
from cowork_local.config import PROVIDER_LABELS
|
||||
if not self._all_image_models:
|
||||
picked = self._combo.currentData()
|
||||
if picked:
|
||||
self._on_status(tr("folder.ai_image_use_selected", model=picked))
|
||||
else:
|
||||
self._on_status(tr("folder.ai_image_none"))
|
||||
return
|
||||
seen, lines = set(), []
|
||||
for key, model in self._all_image_models:
|
||||
tag = (key, model)
|
||||
if tag in seen:
|
||||
continue
|
||||
seen.add(tag)
|
||||
lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})")
|
||||
if len(lines) >= 5:
|
||||
break
|
||||
self._on_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines))
|
||||
|
||||
def image_model(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve ``(model, base_url, api_key)`` for image generation,
|
||||
searching ALL providers — see the module docstring for priority
|
||||
order (picked model if image-capable -> active provider's image
|
||||
model -> any other provider's -> fall back to the picked model)."""
|
||||
from cowork_local.core import image_gen
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return picked, None, None
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
return local, None, None
|
||||
for key, model in self._all_image_models:
|
||||
conf = self.ctx.config.provider_conf(key)
|
||||
return model, (conf.get("base_url") or None), (conf.get("api_key") or None)
|
||||
return (picked or None), None, None
|
||||
|
||||
|
||||
__all__ = ["AiEditModelResolver"]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""AiEditPipeline — the plan-then-edit-then-apply state machine behind the
|
||||
AI-Edit panel (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 1068-1097/1119-1146/1147-1467 of the original 1587-line file:
|
||||
``_ai_start`` through ``_ai_failed``, minus the queue/busy-badge bookkeeping
|
||||
which stays on ``ai_file_editor_dialog.py::AiFileEditorDialog`` — see that
|
||||
module's docstring for the split rationale).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``AiFileEditorDialog`` — same
|
||||
composition-to-respect-the-400-line-cap pattern as
|
||||
``office_document_renderer.py``. Talks to the file only through
|
||||
``document_preview_manager.py``'s public API (``ensure_editable_for_ai``,
|
||||
``write_content``, ``create_new_file``) — it never touches disk itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import parse_ai_output
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
|
||||
class AiEditPipeline:
|
||||
"""Runs one instruction through PLAN -> EDIT -> (review) -> APPLY/DISCARD.
|
||||
|
||||
Args:
|
||||
owner: the ``AiFileEditorDialog`` — supplies ``ai_chat``, ``preview``
|
||||
(``DocumentPreviewManager``), ``resolver``
|
||||
(``AiEditModelResolver``), ``ctx``, ``cowork_context()``, and is
|
||||
told about status changes via ``on_busy_changed``/``on_flag_done``
|
||||
so the panel's queue/badge bookkeeping stays in one place.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self.worker: Optional[AgentWorker] = None
|
||||
self.pending: Optional[dict] = None # proposed content awaiting confirmation
|
||||
self._ctx: dict = {}
|
||||
self._prompt_usage: dict = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._running_file = ""
|
||||
|
||||
def start(self, instruction: str) -> None:
|
||||
"""Begin processing one instruction. Assumes the pipeline is idle
|
||||
(the panel's queue calls this when the previous run finishes)."""
|
||||
o = self._owner
|
||||
preview = o.preview
|
||||
editable = preview.stack.currentWidget() is preview.editor
|
||||
if not editable:
|
||||
editable = preview.ensure_editable_for_ai()
|
||||
o.resolver.maybe_suggest_image_model(instruction)
|
||||
o.resolver.apply_routing(instruction) # may switch to the best coding model
|
||||
has_file = editable and bool(preview.current_file)
|
||||
self._running_file = Path(preview.current_file).name if has_file else tr("folder.ai_new_file")
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_running", name=self._running_file))
|
||||
# Two phases so the PLAN is shown INLINE *before* the edit runs.
|
||||
self._ctx = {
|
||||
"filename": Path(preview.current_file).name if has_file else "",
|
||||
"content": preview.editor.toPlainText() if has_file else "",
|
||||
"convo": o.cowork_context(),
|
||||
"instruction": instruction,
|
||||
"provider": o.resolver.provider(),
|
||||
"plan": "",
|
||||
"edit_kind": preview.edit_kind,
|
||||
}
|
||||
self._prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._run_plan()
|
||||
|
||||
# ---- usage accounting (like Cowork's per-message footer) --------------- #
|
||||
def _add_usage(self, usage) -> None:
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
tot = self._prompt_usage
|
||||
tot["in"] += int(usage.get("in", 0) or 0)
|
||||
tot["out"] += int(usage.get("out", 0) or 0)
|
||||
tot["cache"] += int(usage.get("cache", 0) or 0)
|
||||
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
|
||||
|
||||
def _show_usage(self, bubble) -> None:
|
||||
tot = self._prompt_usage
|
||||
if bubble is None or not (tot["in"] or tot["out"]):
|
||||
return
|
||||
from cowork_local.core import model_pricing as mp, usage_tracker as ut
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self._owner.ctx.config.data.get("usage") or {})}
|
||||
line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} "
|
||||
f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} "
|
||||
f"{ut.format_cost(tot['cost'], pricing)}")
|
||||
try:
|
||||
bubble.add_usage(line)
|
||||
except Exception: # noqa: BLE001 - a usage footer must never break the edit
|
||||
pass
|
||||
|
||||
# ---- phase 1: plan ------------------------------------------------------- #
|
||||
def _run_plan(self) -> None:
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
provider = c["provider"]
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for "
|
||||
"the requested change. Plan ONLY — do NOT output any code."}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
messages.append({"role": "user", "content":
|
||||
f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
f"Request: {c['instruction']}"})
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"plan": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, b=plan_bubble: self._plan_done(res, b))
|
||||
worker.failed.connect(lambda err, b=plan_bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _plan_done(self, result, plan_bubble) -> None:
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
plan = ((result or {}).get("plan") or "").strip()
|
||||
self._ctx["plan"] = plan
|
||||
plan_bubble.set_plain(plan or tr("folder.ai_empty"))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
self._run_edit()
|
||||
|
||||
# ---- phase 2: execute (edit the file) ------------------------------------ #
|
||||
def _run_edit(self) -> None:
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the "
|
||||
"1-based SLIDE NUMBER and M the box on that slide. When the user refers to a "
|
||||
"slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide "
|
||||
"3' and leave every other slide's block exactly as-is. Each block has fields "
|
||||
"type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or "
|
||||
"FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 "
|
||||
"color=FF0000`. Keep all block markers and structure.") if c["edit_kind"] == "pptx" else ""
|
||||
|
||||
_pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck",
|
||||
"スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình")
|
||||
wants_new_pptx = (c["edit_kind"] != "pptx"
|
||||
and any(w in c["instruction"].lower() for w in _pptx_words))
|
||||
new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: <name>.pptx` and output the slides "
|
||||
"as marker blocks — one block per shape:\n"
|
||||
"### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n"
|
||||
"font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n"
|
||||
"### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n"
|
||||
"text:\nBullet one\nBullet two\n\n"
|
||||
"Increment the Slide number for each new slide; pos/size are in inches; "
|
||||
"font color is RRGGBB hex.") if wants_new_pptx else ""
|
||||
|
||||
imggen_note = ""
|
||||
try:
|
||||
from cowork_local.core import image_gen
|
||||
if image_gen.is_configured(o.ctx.config):
|
||||
imggen_note = ("\nYou can also GENERATE an illustration image: add a line "
|
||||
"`IMAGE_GEN: <describe the image> => <relative/path.png>`. Use a "
|
||||
"generated image e.g. as a new picture, or (for pptx) set a picture "
|
||||
"box's `image:` field to that same path to insert it.")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def job(worker):
|
||||
provider = c["provider"]
|
||||
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
|
||||
else "no file is open")
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor inside an app. Following the plan, output the "
|
||||
"COMPLETE file content in ONE fenced code block (```), and nothing after "
|
||||
"it. Preserve everything you were not asked to change.\n"
|
||||
"If the request is to CREATE A NEW file (or a different file than the one "
|
||||
"open), put a line `FILE: <relative/path/name.ext>` (relative to the "
|
||||
"current folder) immediately before the code block. Omit FILE to edit the "
|
||||
f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
if c["plan"]:
|
||||
messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]})
|
||||
cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
if c["filename"] else "No file is currently open.\n\n")
|
||||
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
|
||||
|
||||
def on_text(piece: str) -> None:
|
||||
worker.emit_event({"type": "text", "delta": piece})
|
||||
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"text": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.event.connect(lambda ev, b=bubble: self._stream(ev, b))
|
||||
worker.finished_ok.connect(lambda res, b=bubble: self._done(res, b))
|
||||
worker.failed.connect(lambda err, b=bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stream(self, ev, bubble) -> None:
|
||||
if isinstance(ev, dict) and ev.get("type") == "text":
|
||||
bubble.append_delta(ev.get("delta", ""))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
|
||||
def _done(self, result, bubble) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
self._show_usage(bubble)
|
||||
text = ((result or {}).get("text") or "").strip()
|
||||
target, new_content, summary, image_gens = parse_ai_output(text)
|
||||
if new_content is None and not image_gens:
|
||||
bubble.set_markdown(text or tr("folder.ai_empty"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
return
|
||||
create = bool(target) and (not o.preview.current_file
|
||||
or Path(target).name != Path(o.preview.current_file).name)
|
||||
self.pending = {"content": new_content, "target": target if create else None,
|
||||
"image_gens": image_gens}
|
||||
hint = tr("folder.ai_review_hint")
|
||||
bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_")
|
||||
if new_content is not None:
|
||||
old = "" if create else o.preview.editor.toPlainText()
|
||||
diff = "".join(difflib.unified_diff(
|
||||
old.splitlines(keepends=True), new_content.splitlines(keepends=True),
|
||||
fromfile=("(new file)" if create else "current"),
|
||||
tofile=(target if create else "proposed"))) or "(no textual difference)"
|
||||
title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed")
|
||||
o.ai_chat.add_diff(title, diff)
|
||||
if image_gens:
|
||||
listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens)
|
||||
o.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing)
|
||||
o.show_confirm_row(True)
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
name = target if create else self._running_file
|
||||
o.status_message.emit(tr("folder.ai_proposed_status", name=name))
|
||||
o.set_review_status("● " + hint, current_palette().warning)
|
||||
|
||||
# ---- apply / discard ------------------------------------------------------ #
|
||||
def apply(self) -> None:
|
||||
"""Confirmed by the user. If the edit GENERATES images, ask the
|
||||
image gate then generate them (off-thread) before finalising."""
|
||||
if not self.pending:
|
||||
return
|
||||
p = self.pending
|
||||
self.pending = None
|
||||
self._owner.show_confirm_row(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
self._owner.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._generate_then_finalize(p)
|
||||
return
|
||||
self._finalize_apply(p)
|
||||
|
||||
def _generate_then_finalize(self, p: dict) -> None:
|
||||
o = self._owner
|
||||
imgs = p.get("image_gens") or []
|
||||
root = os.path.normpath(o.preview.root)
|
||||
img_model, img_base, img_key = o.resolver.image_model()
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_generating"))
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import image_gen
|
||||
results = []
|
||||
for prompt, rel in imgs:
|
||||
dest = rel if os.path.isabs(rel) else os.path.join(root, rel)
|
||||
dest = os.path.normpath(dest)
|
||||
if os.path.commonpath([dest, root]) != root:
|
||||
results.append((rel, False, "path escapes the folder"))
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
except OSError as exc:
|
||||
results.append((rel, False, str(exc)))
|
||||
continue
|
||||
ok, msg = image_gen.generate_image(o.ctx.config, prompt, dest,
|
||||
model=img_model, base_url=img_base, api_key=img_key)
|
||||
results.append((dest, ok, msg))
|
||||
return {"results": results}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, pp=p: self._images_done(res, pp))
|
||||
worker.failed.connect(lambda err, pp=p: self._images_done({"results": [], "err": err}, pp))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _images_done(self, res: dict, p: dict) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
created = []
|
||||
for dest, ok, msg in res.get("results", []):
|
||||
if ok:
|
||||
created.append(dest)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name))
|
||||
else:
|
||||
o.ai_chat.add_error(tr("folder.ai_image_failed", err=msg))
|
||||
self._finalize_apply(p, images_done=True)
|
||||
if p.get("content") is None and not p.get("target") and created:
|
||||
o.preview.open_file(created[0], reset_ai=False)
|
||||
|
||||
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
|
||||
o = self._owner
|
||||
content = p.get("content")
|
||||
target = p.get("target")
|
||||
if content is None:
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
return
|
||||
if target:
|
||||
dest = o.preview.create_new_file(target, content)
|
||||
if dest is None:
|
||||
return
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name))
|
||||
o.status_message.emit(tr("folder.ai_created", name=Path(dest).name))
|
||||
else:
|
||||
o.preview.editor.setPlainText(content) # live update in the editor/preview
|
||||
o.preview.write_content(content, skip_image_confirm=images_done)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_applied"))
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
|
||||
def discard(self) -> None:
|
||||
o = self._owner
|
||||
self.pending = None
|
||||
o.show_confirm_row(False)
|
||||
o.ai_chat.add_status(tr("folder.ai_discarded"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.set_review_status("", None)
|
||||
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
|
||||
|
||||
def _failed(self, err, bubble) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
bubble.set_markdown(tr("folder.ai_error", err=err))
|
||||
o.set_busy(False)
|
||||
o.status_message.emit(tr("folder.ai_error", err=err))
|
||||
o.flag_done()
|
||||
|
||||
|
||||
__all__ = ["AiEditPipeline"]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""AiFileEditorDialog — the collapsible AI-edit panel of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 701-800/
|
||||
1039-1066/1099-1118/1468-1517 of the original 1587-line file:
|
||||
``_build_ai_panel``, panel open/reset, the instruction queue, and the busy/
|
||||
done status line).
|
||||
|
||||
Despite the name (matching ``docs/refactor/Feature_Architecture_Proposal.md``'s
|
||||
R08-T12 file list), this is an inline collapsible ``QWidget`` panel, not a
|
||||
modal ``QDialog`` — exactly like the original ``_ai_panel`` was.
|
||||
|
||||
Composes two helpers to stay under the 400-line cap:
|
||||
``ai_edit_model_resolver.py::AiEditModelResolver`` (which provider/model
|
||||
answers a run) and ``ai_edit_pipeline.py::AiEditPipeline`` (the actual
|
||||
plan-then-edit-then-apply state machine). This class owns the widget itself,
|
||||
the instruction queue, and the busy/done status line/badge — the parts that
|
||||
needed to stay together because the queue decides when the pipeline's next
|
||||
``start()`` call happens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
|
||||
from cowork_local.presentation.folder.ai_edit_pipeline import AiEditPipeline
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.chat_view import ChatView
|
||||
|
||||
|
||||
class AiFileEditorDialog(QWidget):
|
||||
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
|
||||
OWN model picker + routing toggle, an instruction box, and an Apply/
|
||||
Discard confirmation bar for the proposed edit.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
preview: ``document_preview_manager.py::DocumentPreviewManager`` —
|
||||
every read/write of the actual file content goes through it.
|
||||
cowork: the shared Cowork tab (optional) — its recent messages are
|
||||
included as background context for the edit.
|
||||
"""
|
||||
|
||||
status_message = Signal(str)
|
||||
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
|
||||
|
||||
def __init__(self, ctx, preview, cowork=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.preview = preview
|
||||
self._cowork = cowork
|
||||
self._ai_queue: List[str] = []
|
||||
self.pipeline = AiEditPipeline(self)
|
||||
self.resolver: Optional[AiEditModelResolver] = None # built after ai_model_combo exists
|
||||
|
||||
preview.ai_reset_requested.connect(self.reset_conversation)
|
||||
preview.status_message.connect(self.status_message.emit)
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(6, 0, 0, 0)
|
||||
v.setSpacing(4)
|
||||
title_row = QHBoxLayout()
|
||||
self._ai_title = QLabel(tr("folder.ai_edit"))
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
title_row.addWidget(self._ai_title)
|
||||
title_row.addStretch(1)
|
||||
self._ai_status = QLabel("")
|
||||
self._ai_status.setObjectName("hint")
|
||||
title_row.addWidget(self._ai_status)
|
||||
v.addLayout(title_row)
|
||||
self.ai_chat = ChatView()
|
||||
v.addWidget(self.ai_chat, 1)
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self._ai_model_lbl = QLabel(tr("folder.ai_model_label"))
|
||||
self._ai_model_lbl.setObjectName("hint")
|
||||
model_row.addWidget(self._ai_model_lbl)
|
||||
self.ai_model_combo = QComboBox()
|
||||
self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
model_row.addWidget(self.ai_model_combo, 1)
|
||||
from cowork_local.ui.routing_toggle import RoutingToggle
|
||||
self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit")
|
||||
model_row.addWidget(self.ai_routing_toggle)
|
||||
v.addLayout(model_row)
|
||||
self.resolver = AiEditModelResolver(
|
||||
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.ai_input = QLineEdit()
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_input.returnPressed.connect(self._ai_send)
|
||||
row.addWidget(self.ai_input, 1)
|
||||
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
|
||||
self.ai_send_btn.setObjectName("primary")
|
||||
self.ai_send_btn.clicked.connect(self._ai_send)
|
||||
row.addWidget(self.ai_send_btn)
|
||||
v.addLayout(row)
|
||||
|
||||
self._ai_confirm_row = QWidget()
|
||||
cf = QHBoxLayout(self._ai_confirm_row)
|
||||
cf.setContentsMargins(0, 0, 0, 0)
|
||||
cf.addStretch(1)
|
||||
self._ai_discard_btn = QPushButton(tr("folder.ai_discard"))
|
||||
self._ai_discard_btn.clicked.connect(self.pipeline.discard)
|
||||
cf.addWidget(self._ai_discard_btn)
|
||||
self._ai_apply_btn = QPushButton(tr("folder.ai_apply"))
|
||||
self._ai_apply_btn.setObjectName("primary")
|
||||
self._ai_apply_btn.clicked.connect(self.pipeline.apply)
|
||||
cf.addWidget(self._ai_apply_btn)
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
v.addWidget(self._ai_confirm_row)
|
||||
|
||||
on_language_changed(self.retranslate)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._ai_title.setText(tr("folder.ai_edit"))
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_send_btn.setText(tr("folder.ai_send"))
|
||||
self._ai_model_lbl.setText(tr("folder.ai_model_label"))
|
||||
if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None:
|
||||
self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto"))
|
||||
self._ai_apply_btn.setText(tr("folder.ai_apply"))
|
||||
self._ai_discard_btn.setText(tr("folder.ai_discard"))
|
||||
|
||||
# ---- called by the shell (header button, splitter owner) --------------- #
|
||||
def on_opened(self) -> None:
|
||||
"""The shell's AI toggle button was just checked ON."""
|
||||
self.ai_input.setFocus()
|
||||
if self.resolver.should_refresh():
|
||||
self.resolver.refresh()
|
||||
if self.pipeline.worker is None:
|
||||
self.badge_changed.emit("")
|
||||
self._ai_status.setText("")
|
||||
|
||||
def reset_conversation(self) -> None:
|
||||
"""Clear the AI-edit chat so each file starts a clean conversation. A
|
||||
run in progress (editing the previous file) is left untouched — the
|
||||
reset applies the next time a file is opened while idle."""
|
||||
if self.pipeline.worker is not None:
|
||||
return
|
||||
self.ai_chat.clear()
|
||||
self.badge_changed.emit("")
|
||||
self.pipeline.pending = None
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
self._ai_status.setText("")
|
||||
|
||||
def cowork_context(self) -> str:
|
||||
"""The whole Cowork conversation (recent turns) as background context."""
|
||||
cw = self._cowork
|
||||
msgs = getattr(cw, "messages", None) if cw is not None else None
|
||||
if not msgs:
|
||||
return ""
|
||||
lines = [f"{m['role']}: {str(m['content'])[:1000]}"
|
||||
for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")]
|
||||
return "\n".join(lines[-12:])
|
||||
|
||||
# ---- send / queue -------------------------------------------------------- #
|
||||
def _ai_send(self) -> None:
|
||||
if not self.preview.root:
|
||||
self.ai_chat.add_error(tr("folder.ai_no_file"))
|
||||
return
|
||||
instruction = self.ai_input.text().strip()
|
||||
if not instruction:
|
||||
return
|
||||
self.ai_input.clear()
|
||||
self.ai_chat.add_user(instruction)
|
||||
# QUEUE: while a run is active OR a proposal is awaiting Apply/Discard,
|
||||
# hold the new instruction and run it once the pipeline goes idle.
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
self._ai_queue.append(instruction)
|
||||
self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue)))
|
||||
self._update_queue_status()
|
||||
return
|
||||
self.pipeline.start(instruction)
|
||||
|
||||
def _update_queue_status(self) -> None:
|
||||
n = len(self._ai_queue)
|
||||
if n:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
|
||||
+ " · " + tr("folder.ai_queue_count", n=n))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
|
||||
def maybe_dequeue(self) -> None:
|
||||
"""When the pipeline is fully idle, start the next queued instruction."""
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
return
|
||||
if not self._ai_queue:
|
||||
return
|
||||
nxt = self._ai_queue.pop(0)
|
||||
self._update_queue_status()
|
||||
self.pipeline.start(nxt)
|
||||
|
||||
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self.ai_input.setEnabled(not busy)
|
||||
self.ai_send_btn.setEnabled(not busy)
|
||||
if busy:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
self.badge_changed.emit(" ⏳") # visible even when collapsed
|
||||
else:
|
||||
self._ai_status.setText("")
|
||||
self.badge_changed.emit("")
|
||||
|
||||
def flag_done(self) -> None:
|
||||
"""After a background run, show a 'done' badge so the user notices
|
||||
the result when they return to the tab; cleared on reopen. If more
|
||||
instructions are queued, start the next one instead."""
|
||||
if self.pipeline.worker is None and self.pipeline.pending is None and self._ai_queue:
|
||||
self.maybe_dequeue()
|
||||
return
|
||||
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
|
||||
self.badge_changed.emit(" ✓")
|
||||
|
||||
def show_confirm_row(self, visible: bool) -> None:
|
||||
self._ai_confirm_row.setVisible(visible)
|
||||
|
||||
def set_review_status(self, text: str, color) -> None:
|
||||
self._ai_status.setText(text)
|
||||
if color:
|
||||
self._ai_status.setStyleSheet(f"color:{color};")
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this AI-Edit run to another model."""
|
||||
from cowork_local.ui.routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
|
||||
__all__ = ["AiFileEditorDialog"]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split
|
||||
out of ``document_preview_manager.py`` to keep that file under the 400-line
|
||||
cap; originally ``ui/folder_tab.py``, lines 61-236 of the original
|
||||
1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``,
|
||||
``_LineNumbers``, ``CodeEditor``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QRect, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
||||
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
||||
|
||||
|
||||
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
|
||||
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
||||
f = QTextCharFormat()
|
||||
f.setForeground(QColor(color))
|
||||
if italic:
|
||||
f.setFontItalic(True)
|
||||
if bold:
|
||||
f.setFontWeight(QFont.Bold)
|
||||
return f
|
||||
|
||||
|
||||
class PygmentsHighlighter(QSyntaxHighlighter):
|
||||
"""Colour the whole document with Pygments and apply per-block. Re-lexes the
|
||||
full text (debounced) so multi-line strings/comments colour correctly."""
|
||||
|
||||
def __init__(self, document):
|
||||
super().__init__(document)
|
||||
from pygments.lexers.special import TextLexer
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._ranges: list[tuple[int, int, QTextCharFormat]] = []
|
||||
self._rules = self._build_rules()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.setInterval(250)
|
||||
self._timer.timeout.connect(self._retokenize)
|
||||
document.contentsChanged.connect(self._timer.start)
|
||||
|
||||
@staticmethod
|
||||
def _build_rules():
|
||||
from pygments.token import (
|
||||
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
|
||||
)
|
||||
p = current_palette()
|
||||
return [
|
||||
(Comment, _fmt(p.code_comment, italic=True)),
|
||||
(Keyword.Type, _fmt(p.code_type)),
|
||||
(Keyword, _fmt(p.code_keyword)),
|
||||
(Name.Function, _fmt(p.code_func)),
|
||||
(Name.Class, _fmt(p.code_type)),
|
||||
(Name.Decorator, _fmt(p.code_func)),
|
||||
(Name.Builtin, _fmt(p.code_type)),
|
||||
(Name.Tag, _fmt(p.code_keyword)),
|
||||
(Name.Attribute, _fmt(p.code_attr)),
|
||||
(String.Doc, _fmt(p.code_comment, italic=True)),
|
||||
(String, _fmt(p.code_string)),
|
||||
(Number, _fmt(p.code_number)),
|
||||
(Operator, _fmt(p.code_fg)),
|
||||
(Punctuation, _fmt(p.code_fg)),
|
||||
(Error, _fmt(p.code_error)),
|
||||
]
|
||||
|
||||
def set_filename(self, filename: str, text: str = "") -> None:
|
||||
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
||||
from pygments.lexers.special import TextLexer
|
||||
from pygments.util import ClassNotFound
|
||||
try:
|
||||
self._lexer = get_lexer_for_filename(filename, stripnl=False)
|
||||
except ClassNotFound:
|
||||
try:
|
||||
self._lexer = guess_lexer(text) if text.strip() else TextLexer()
|
||||
except ClassNotFound:
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._retokenize()
|
||||
|
||||
def _fmt_for(self, tok):
|
||||
for ttype, fmt in self._rules:
|
||||
if tok in ttype:
|
||||
return fmt
|
||||
return None
|
||||
|
||||
def _retokenize(self) -> None:
|
||||
from pygments import lex
|
||||
text = self.document().toPlainText()
|
||||
self._ranges = []
|
||||
if len(text) <= _MAX_HIGHLIGHT_CHARS:
|
||||
pos = 0
|
||||
for tok, val in lex(text, self._lexer):
|
||||
fmt = self._fmt_for(tok)
|
||||
if fmt is not None and val:
|
||||
self._ranges.append((pos, pos + len(val), fmt))
|
||||
pos += len(val)
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
|
||||
if not self._ranges:
|
||||
return
|
||||
bstart = self.currentBlock().position()
|
||||
bend = bstart + len(text)
|
||||
for start, end, fmt in self._ranges:
|
||||
if end <= bstart or start >= bend:
|
||||
continue
|
||||
s = max(start, bstart) - bstart
|
||||
e = min(end, bend) - bstart
|
||||
if e > s:
|
||||
self.setFormat(s, e - s, fmt)
|
||||
|
||||
|
||||
class _LineNumbers(QWidget):
|
||||
def __init__(self, editor):
|
||||
super().__init__(editor)
|
||||
self._editor = editor
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(self._editor.line_number_width(), 0)
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
self._editor.paint_line_numbers(event)
|
||||
|
||||
|
||||
class CodeEditor(QPlainTextEdit):
|
||||
"""A dark, monospaced editor with a line-number gutter + Pygments colouring —
|
||||
the Sublime/VS-Code look for viewing & editing source files."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("codeEditor")
|
||||
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
|
||||
font = QFont("Consolas")
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
font.setPointSize(10)
|
||||
self.setFont(font)
|
||||
self._gutter = _LineNumbers(self)
|
||||
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
|
||||
self.updateRequest.connect(self._on_update_request)
|
||||
self._highlighter = PygmentsHighlighter(self.document())
|
||||
self._update_gutter_width()
|
||||
|
||||
def line_number_width(self) -> int:
|
||||
digits = max(2, len(str(max(1, self.blockCount()))))
|
||||
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
|
||||
|
||||
def _update_gutter_width(self) -> None:
|
||||
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
|
||||
|
||||
def _on_update_request(self, rect, dy: int) -> None:
|
||||
if dy:
|
||||
self._gutter.scroll(0, dy)
|
||||
else:
|
||||
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
||||
if rect.contains(self.viewport().rect()):
|
||||
self._update_gutter_width()
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802
|
||||
super().resizeEvent(event)
|
||||
cr = self.contentsRect()
|
||||
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
|
||||
|
||||
def paint_line_numbers(self, event) -> None:
|
||||
p = current_palette()
|
||||
painter = QPainter(self._gutter)
|
||||
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
|
||||
block = self.firstVisibleBlock()
|
||||
num = block.blockNumber()
|
||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
painter.setPen(QColor(p.code_gutter_fg))
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
painter.drawText(0, int(top), self._gutter.width() - 6,
|
||||
self.fontMetrics().height(), Qt.AlignRight,
|
||||
str(num + 1))
|
||||
block = block.next()
|
||||
top = bottom
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
num += 1
|
||||
|
||||
def load_file(self, path: str, text: str) -> None:
|
||||
self.setPlainText(text)
|
||||
self._highlighter.set_filename(path, text)
|
||||
|
||||
|
||||
__all__ = ["CodeEditor", "PygmentsHighlighter"]
|
||||
@@ -0,0 +1,304 @@
|
||||
"""DocumentPreviewManager — the view/edit pane of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 295-373/
|
||||
410-449/649-699 of the original 1587-line file: the preview
|
||||
``QStackedWidget`` + open/save/create/external dispatch. HTML/PPTX/Excel/
|
||||
PDF/office rendering lives in ``office_document_renderer.py``; the code
|
||||
editor widget lives in ``code_editor.py`` — both split out to keep this file
|
||||
under the 400-line cap.
|
||||
|
||||
**Closes the R06-T05 loop**: ``application/workspaces/file_workspace_service.
|
||||
py::FileWorkspaceService`` existed since R06 but had zero production call
|
||||
sites (confirmed by grep before this task — ``ui/folder_tab.py`` wrote files
|
||||
with raw ``Path.write_text`` instead). Every plain-text write this class does
|
||||
(``save``, ``create_new_file``, ``write_content``) now goes through it —
|
||||
same path-containment check, same auto ``mkdir``, and (new, from
|
||||
``infrastructure/filesystem/file_tools.py::write_file``) a Python-syntax
|
||||
warning on a bad ``.py`` write, which the original code never had. A ``.pptx``
|
||||
save still goes through ``core/pptx_edit.py`` directly — that's a binary
|
||||
package build, not a text write, and ``FileWorkspaceService`` has no opinion
|
||||
on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget,
|
||||
QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text, pptx_available, read_text,
|
||||
)
|
||||
from cowork_local.domain.workspaces.workspace_session import WorkspaceSession
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.folder.code_editor import CodeEditor
|
||||
from cowork_local.presentation.folder.office_document_renderer import OfficeDocumentRenderer
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.libreoffice_view import DOC_SUFFIXES
|
||||
|
||||
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
|
||||
_HTML_SUFFIXES = {".html", ".htm"}
|
||||
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
|
||||
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
|
||||
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
|
||||
|
||||
|
||||
class DocumentPreviewManager(QWidget):
|
||||
"""View/edit pane: header (file name, Preview⇄Edit toggle, Save, Open
|
||||
externally) above a ``QStackedWidget`` that renders whichever preview a
|
||||
file's suffix calls for."""
|
||||
|
||||
status_message = Signal(str)
|
||||
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
|
||||
|
||||
def __init__(self, root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
self._office = OfficeDocumentRenderer(self)
|
||||
self._edit_kind: Optional[str] = None # None | "html" | "pptx"
|
||||
self._current_file: Optional[str] = None
|
||||
|
||||
rl = QVBoxLayout(self)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self.file_label = QLabel("")
|
||||
self.file_label.setStyleSheet("font-weight:600;")
|
||||
self.file_label.setWordWrap(True)
|
||||
hdr.addWidget(self.file_label, 1)
|
||||
self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX)
|
||||
self.mode_btn.setCheckable(True)
|
||||
self.mode_btn.clicked.connect(self._office.toggle_edit_mode)
|
||||
self.mode_btn.setVisible(False)
|
||||
hdr.addWidget(self.mode_btn)
|
||||
self.save_btn = QPushButton()
|
||||
self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.clicked.connect(self.save)
|
||||
self.save_btn.setVisible(False)
|
||||
hdr.addWidget(self.save_btn)
|
||||
self.ext_btn = QPushButton()
|
||||
self.ext_btn.setIcon(icon("upload"))
|
||||
self.ext_btn.clicked.connect(self.open_external)
|
||||
self.ext_btn.setVisible(False)
|
||||
hdr.addWidget(self.ext_btn)
|
||||
rl.addLayout(hdr)
|
||||
# Exposed so the shell can insert its own AI-panel toggle button into
|
||||
# this same header row (between mode_btn and save_btn, matching the
|
||||
# original single-class layout) without this class knowing the AI
|
||||
# panel exists.
|
||||
self.header_layout = hdr
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self._placeholder = QLabel("")
|
||||
self._placeholder.setObjectName("hint")
|
||||
self._placeholder.setAlignment(Qt.AlignCenter)
|
||||
self.stack.addWidget(self._placeholder) # 0
|
||||
|
||||
self.editor = CodeEditor() # 1
|
||||
self.stack.addWidget(self.editor)
|
||||
|
||||
self.web = QTextBrowser() # 2
|
||||
self.web.setOpenExternalLinks(True)
|
||||
self.stack.addWidget(self.web)
|
||||
|
||||
self.doc_view = QTextBrowser() # 3
|
||||
self.doc_view.setObjectName("docPreview")
|
||||
self.stack.addWidget(self.doc_view)
|
||||
|
||||
self._img_scroll = QScrollArea() # 4
|
||||
self._img_scroll.setWidgetResizable(True)
|
||||
self._img_label = QLabel("")
|
||||
self._img_label.setAlignment(Qt.AlignCenter)
|
||||
self._img_scroll.setWidget(self._img_label)
|
||||
self.stack.addWidget(self._img_scroll)
|
||||
|
||||
rl.addWidget(self.stack, 1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
if not self._current_file:
|
||||
self._placeholder.setText(tr("folder.select_file"))
|
||||
self._retranslate_mode_btn()
|
||||
|
||||
def _retranslate_mode_btn(self) -> None:
|
||||
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
|
||||
else tr("folder.preview"))
|
||||
|
||||
# ---- public API used by the shell / AI panel --------------------------- #
|
||||
@property
|
||||
def current_file(self) -> Optional[str]:
|
||||
return self._current_file
|
||||
|
||||
@property
|
||||
def edit_kind(self) -> Optional[str]:
|
||||
return self._edit_kind
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, root: str) -> None:
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
|
||||
def open_file(self, path: str, reset_ai: bool = True) -> None:
|
||||
# Switching to a DIFFERENT file starts a fresh AI-edit conversation
|
||||
# (reset_ai=False when the AI itself just CREATED this file — keep
|
||||
# that chat). Whether/how to reset is the AI panel's own business —
|
||||
# this class only announces that a genuine file switch happened.
|
||||
if reset_ai and path != self._current_file:
|
||||
self.ai_reset_requested.emit()
|
||||
self._current_file = path
|
||||
self.file_label.setText(path)
|
||||
suffix = Path(path).suffix.lower()
|
||||
self.mode_btn.setVisible(False)
|
||||
self.save_btn.setVisible(False)
|
||||
self.ext_btn.setVisible(False)
|
||||
self._edit_kind = None
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
if suffix in _IMAGE_SUFFIXES:
|
||||
self._show_image(path)
|
||||
elif suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=True)
|
||||
elif suffix in _EXCEL_SUFFIXES:
|
||||
self._office.show_excel(path)
|
||||
elif suffix in DOC_SUFFIXES:
|
||||
self._office.show_document(path)
|
||||
elif size > _MAX_EDIT_BYTES or not is_probably_text(path):
|
||||
self._show_binary(path)
|
||||
else:
|
||||
self._show_code(path)
|
||||
|
||||
def ensure_editable_for_ai(self) -> bool:
|
||||
"""Make the current file editable in the code editor (switching an
|
||||
HTML preview to edit, or loading a text file). Returns False when
|
||||
there's no file open or it isn't a text/code file."""
|
||||
path = self._current_file
|
||||
if not path or not os.path.isfile(path):
|
||||
return False
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES:
|
||||
return False
|
||||
if is_probably_text(path):
|
||||
self._show_code(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self) -> None:
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(self.editor.toPlainText()):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, self.editor.toPlainText())
|
||||
self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
|
||||
def write_content(self, content: str, skip_image_confirm: bool = False) -> None:
|
||||
"""Persist AI-confirmed content to disk AND refresh the preview.
|
||||
pptx text is written back into the deck (no PowerPoint window)."""
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(content, skip_confirm=skip_image_confirm):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return
|
||||
suffix = Path(self._current_file).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(self._current_file, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES:
|
||||
self._office.show_pptx(self._current_file, mode_preview=True)
|
||||
|
||||
def create_new_file(self, target: str, content: str) -> Optional[str]:
|
||||
"""Create ``target`` (relative to the folder root) with ``content``
|
||||
and open it — like Cowork's save_file. Refuses paths escaping the
|
||||
root (enforced by ``FileWorkspaceService``/``WorkspaceSession``)."""
|
||||
root = os.path.normpath(self._root)
|
||||
dest = target if os.path.isabs(target) else os.path.join(root, target)
|
||||
dest = os.path.normpath(dest)
|
||||
try:
|
||||
if Path(dest).suffix.lower() in _PPTX_SUFFIXES and pptx_available():
|
||||
# A .pptx is a binary package — build a real deck from the
|
||||
# marker text (writing text straight to .pptx would corrupt it).
|
||||
from cowork_local.core import pptx_edit
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
pptx_edit.create_pptx_from_text(dest, content)
|
||||
else:
|
||||
self._write_plain_text(dest, content)
|
||||
except Exception as exc: # noqa: BLE001 - OS error, containment error, or pptx build failure
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return None
|
||||
self.open_file(dest, reset_ai=False) # show the new file; keep the AI chat
|
||||
return dest
|
||||
|
||||
def open_external(self) -> None:
|
||||
if self._current_file:
|
||||
from cowork_local.ui.osutil import open_location
|
||||
open_location(self._current_file)
|
||||
|
||||
# ---- writes ------------------------------------------------------------- #
|
||||
def _write_plain_text(self, path: str, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` (must resolve inside the current
|
||||
root) via ``FileWorkspaceService`` — same containment check, ``mkdir``
|
||||
and Python-syntax warning the agent's own ``write_file`` tool gets."""
|
||||
rel = os.path.relpath(path, self._root)
|
||||
result = self._file_service.write_file(rel, content)
|
||||
if not result.get("ok"):
|
||||
raise OSError(result.get("output") or "write failed")
|
||||
|
||||
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
|
||||
# office_document_renderer.py) --------------------------------------------- #
|
||||
def _show_code(self, path: str) -> None:
|
||||
text = read_text(path)
|
||||
self.editor.setReadOnly(False)
|
||||
self.editor.load_file(path, text)
|
||||
self.save_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self.editor)
|
||||
|
||||
def _show_image(self, path: str) -> None:
|
||||
from PySide6.QtGui import QPixmap
|
||||
pix = QPixmap(path)
|
||||
if pix.isNull():
|
||||
self._show_binary(path)
|
||||
return
|
||||
self._img_label.setPixmap(pix)
|
||||
self._img_label.resize(pix.size())
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._img_scroll)
|
||||
|
||||
def _show_binary(self, path: str) -> None:
|
||||
self._placeholder.setText(tr("folder.binary_file"))
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._placeholder)
|
||||
|
||||
|
||||
__all__ = ["DocumentPreviewManager"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""FolderTab shell (R08-T12) — assembles
|
||||
``workspace_file_tree.py::WorkspaceFileTree``,
|
||||
``document_preview_manager.py::DocumentPreviewManager`` and
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` behind the splitter/terminal
|
||||
layout that used to be inline in ``ui/folder_tab.py::FolderTab.__init__``
|
||||
(lines 238-384 of the original 1587-line file).
|
||||
|
||||
The AI-panel toggle button (``ai_btn``) lives here because it controls
|
||||
things two different children own: the panel's own visibility AND the
|
||||
content splitter's sizing — a genuine shell-level concern, not either
|
||||
child's.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_file_editor_dialog import AiFileEditorDialog
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class FolderTab(QWidget):
|
||||
"""Two-pane file explorer: directory tree + view/edit pane (+ collapsible
|
||||
AI-edit panel, + collapsible terminal)."""
|
||||
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._root = str(ctx.config.cowork_output_dir())
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
|
||||
self.tree = WorkspaceFileTree(self._root)
|
||||
split.addWidget(self.tree)
|
||||
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.preview = DocumentPreviewManager(self._root)
|
||||
self.ai_panel = AiFileEditorDialog(ctx, self.preview, cowork=cowork)
|
||||
|
||||
self.ai_btn = QPushButton() # expand/collapse the AI-edit panel
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.setCheckable(True)
|
||||
self.ai_btn.clicked.connect(self._toggle_ai_panel)
|
||||
# Same visual position as the original single-class header: between
|
||||
# the Preview⇄Edit toggle and Save (file_label=0, mode_btn=1).
|
||||
self.preview.header_layout.insertWidget(2, self.ai_btn)
|
||||
self.ai_panel.badge_changed.connect(self._on_ai_badge_changed)
|
||||
|
||||
content_split = QSplitter(Qt.Horizontal)
|
||||
content_split.addWidget(self.preview)
|
||||
content_split.addWidget(self.ai_panel)
|
||||
content_split.setStretchFactor(0, 1)
|
||||
content_split.setStretchFactor(1, 0)
|
||||
content_split.setSizes([700, 320])
|
||||
self._content_split = content_split
|
||||
self.ai_panel.setVisible(False) # default collapsed
|
||||
rl.addWidget(content_split, 1)
|
||||
|
||||
split.addWidget(right)
|
||||
split.setStretchFactor(0, 0)
|
||||
split.setStretchFactor(1, 1)
|
||||
split.setSizes([300, 800])
|
||||
root_layout.addWidget(split, 1)
|
||||
|
||||
# Terminal CLI below the file view — collapsible, default collapsed;
|
||||
# opening it points the shell at the current workspace folder.
|
||||
from cowork_local.ui.terminal_panel import TerminalPanel
|
||||
|
||||
self.terminal = TerminalPanel()
|
||||
self.terminal.set_cwd(self._root)
|
||||
self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root))
|
||||
root_layout.addWidget(self.terminal)
|
||||
|
||||
self.tree.file_selected.connect(self.preview.open_file)
|
||||
self.preview.status_message.connect(self.status_message.emit)
|
||||
self.ai_panel.status_message.connect(self.status_message.emit)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
def set_root(self, path: str) -> None:
|
||||
self.tree.set_root(path)
|
||||
# WorkspaceFileTree silently no-ops on an invalid path (same guard
|
||||
# the original single-class _root setter had) — mirror that here by
|
||||
# only propagating when the tree actually accepted it.
|
||||
if self.tree.root == path:
|
||||
self._root = path
|
||||
self.preview.set_root(path)
|
||||
self.terminal.set_cwd(path)
|
||||
|
||||
def _toggle_ai_panel(self) -> None:
|
||||
show = self.ai_btn.isChecked()
|
||||
self.ai_panel.setVisible(show)
|
||||
if show:
|
||||
self._content_split.setSizes([700, 320])
|
||||
self.ai_panel.on_opened()
|
||||
|
||||
def _on_ai_badge_changed(self, suffix: str) -> None:
|
||||
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.tree.retranslate()
|
||||
self.preview.retranslate()
|
||||
self.ai_panel.retranslate()
|
||||
self.ai_btn.setText(tr("folder.ai_edit"))
|
||||
self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip"))
|
||||
|
||||
|
||||
__all__ = ["FolderTab"]
|
||||
@@ -0,0 +1,269 @@
|
||||
"""OfficeDocumentRenderer — HTML/PPTX/Excel/PDF/office-doc preview for
|
||||
``document_preview_manager.py`` (R08-T12, split out to keep that file under
|
||||
the 400-line cap; originally ``ui/folder_tab.py``, lines 451-647/679-694 of
|
||||
the original 1587-line file).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY a ``DocumentPreviewManager``
|
||||
rather than a widget of its own: these renderers are tightly coupled to the
|
||||
manager's shared ``QStackedWidget``/toolbar/editor — genuinely one screen's
|
||||
internal state, not an independent concern — so this is a composition split
|
||||
to respect the line-count cap, the same way
|
||||
``presentation/scheduling/kanban_board_widget.py`` composes
|
||||
``TaskApplicationService`` rather than owning that logic inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import QTabWidget, QTableWidget, QTableWidgetItem
|
||||
|
||||
from cowork_local.application.workspaces.file_preview_helpers import read_text
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
|
||||
try:
|
||||
from PySide6.QtPdf import QPdfDocument # noqa: F401
|
||||
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
|
||||
HAS_PDF = True
|
||||
except Exception: # pragma: no cover - QtPdf not bundled
|
||||
HAS_PDF = False
|
||||
|
||||
|
||||
class OfficeDocumentRenderer:
|
||||
"""Renders HTML/PPTX/Excel/PDF/office docs into ``owner.stack``.
|
||||
|
||||
``owner`` is the ``DocumentPreviewManager`` — this class reaches into
|
||||
``owner.stack``/``owner.editor``/``owner.mode_btn``/``owner.ext_btn``/
|
||||
``owner.save_btn``/``owner.doc_view``/``owner.web`` because those widgets
|
||||
are shared with the manager's simpler renderers (code/image/binary);
|
||||
duplicating them here would mean two stacked widgets fighting over which
|
||||
one is "the" preview.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self._engine = None
|
||||
self._pdf_view = None
|
||||
self._pdf_doc = None
|
||||
self._pdf_tmp: Optional[str] = None
|
||||
self._pdf_cache: dict = {}
|
||||
self._convert_worker = None
|
||||
self._xlsx_view = None
|
||||
|
||||
def show_html(self, path: str, mode_preview: bool) -> None:
|
||||
o = self._owner
|
||||
o._edit_kind = "html"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
if mode_preview:
|
||||
from PySide6.QtCore import QUrl
|
||||
html = read_text(path)
|
||||
engine = self._ensure_engine()
|
||||
if engine is not None:
|
||||
engine.setHtml(html, QUrl.fromLocalFile(path))
|
||||
o.stack.setCurrentWidget(engine)
|
||||
else:
|
||||
o.web.setHtml(html)
|
||||
o.stack.setCurrentWidget(o.web)
|
||||
o.save_btn.setVisible(False)
|
||||
else:
|
||||
o._show_code(path)
|
||||
|
||||
def show_pptx(self, path: str, mode_preview: bool) -> None:
|
||||
"""PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the
|
||||
deck's text (marker-delimited per box) in the editor."""
|
||||
o = self._owner
|
||||
o._edit_kind = "pptx"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
o.ext_btn.setVisible(True)
|
||||
if mode_preview:
|
||||
self.show_document(path) # PDF render of the slides
|
||||
o.mode_btn.setVisible(True) # show_document doesn't touch it
|
||||
else:
|
||||
from cowork_local.core.pptx_edit import pptx_to_text
|
||||
try:
|
||||
text = pptx_to_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"[could not read pptx text: {exc}]"
|
||||
o.editor.setReadOnly(False)
|
||||
o.editor.load_file(path + ".txt", text) # .txt → plain highlighting
|
||||
o.save_btn.setVisible(True)
|
||||
o.stack.setCurrentWidget(o.editor)
|
||||
|
||||
def _ensure_engine(self):
|
||||
"""Create the QWebEngineView on first HTML preview (only when WebEngine
|
||||
is safe to use); otherwise stay on the QTextBrowser fallback."""
|
||||
if not HAS_WEB_ENGINE:
|
||||
return None
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
self._engine = QWebEngineView()
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
return self._engine
|
||||
|
||||
def toggle_edit_mode(self) -> None:
|
||||
o = self._owner
|
||||
if not o.current_file:
|
||||
return
|
||||
preview = not o.mode_btn.isChecked() # checked = Edit
|
||||
if o._edit_kind == "pptx":
|
||||
self.show_pptx(o.current_file, mode_preview=preview)
|
||||
else:
|
||||
self.show_html(o.current_file, mode_preview=preview)
|
||||
|
||||
def show_excel(self, path: str) -> None:
|
||||
"""View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
ensure_module("openpyxl", "openpyxl")
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text
|
||||
self.show_document(path)
|
||||
return
|
||||
MAX_ROWS, MAX_COLS = 2000, 100
|
||||
if self._xlsx_view is None:
|
||||
self._xlsx_view = QTabWidget()
|
||||
o.stack.addWidget(self._xlsx_view)
|
||||
tabs = self._xlsx_view
|
||||
while tabs.count():
|
||||
w = tabs.widget(0); tabs.removeTab(0); w.deleteLater()
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||
ncols = max((len(r) for r in rows), default=0)
|
||||
table = QTableWidget(len(rows), ncols)
|
||||
table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
table.horizontalHeader().setVisible(False)
|
||||
for r, row in enumerate(rows):
|
||||
for c, val in enumerate(row):
|
||||
if val is not None:
|
||||
table.setItem(r, c, QTableWidgetItem(str(val)))
|
||||
table.resizeColumnsToContents()
|
||||
title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS
|
||||
or (ws.max_column or 0) > MAX_COLS else "")
|
||||
tabs.addTab(table, title)
|
||||
finally:
|
||||
wb.close()
|
||||
if tabs.count() == 0:
|
||||
self.show_document(path)
|
||||
return
|
||||
o.stack.setCurrentWidget(tabs)
|
||||
|
||||
def show_document(self, path: str) -> None:
|
||||
"""Office docs + PDF are RENDERED via QtPdf — LibreOffice converts
|
||||
them to PDF first. Falls back to text extraction when QtPdf/
|
||||
LibreOffice aren't available."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
suffix = Path(path).suffix.lower()
|
||||
if not HAS_PDF:
|
||||
self.show_document_text(path)
|
||||
return
|
||||
if suffix == ".pdf":
|
||||
self._render_pdf(path)
|
||||
return
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
mtime = 0
|
||||
cached = self._pdf_cache.get((path, mtime))
|
||||
if cached and os.path.exists(cached):
|
||||
self._render_pdf(cached)
|
||||
return
|
||||
from cowork_local.core.doc_extract import convert_to_pdf, find_soffice
|
||||
if not find_soffice() and os.name != "nt":
|
||||
self.show_document_text(path)
|
||||
return
|
||||
o.doc_view.setPlainText(tr("folder.converting"))
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
if self._pdf_tmp is None:
|
||||
import tempfile
|
||||
self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_")
|
||||
src, out_dir = path, self._pdf_tmp
|
||||
|
||||
def job(worker):
|
||||
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
|
||||
|
||||
def done(result):
|
||||
if result.get("src") != o.current_file:
|
||||
return # user moved on to another file
|
||||
pdf = result.get("pdf")
|
||||
if pdf:
|
||||
self._pdf_cache[(result["src"], result["mtime"])] = pdf
|
||||
self._render_pdf(pdf)
|
||||
else:
|
||||
self.show_document_text(src)
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(done)
|
||||
worker.failed.connect(lambda _e, p=src: self.show_document_text(p))
|
||||
self._convert_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ensure_pdf_view(self):
|
||||
if not HAS_PDF:
|
||||
return None
|
||||
if self._pdf_view is None:
|
||||
from PySide6.QtPdf import QPdfDocument
|
||||
from PySide6.QtPdfWidgets import QPdfView
|
||||
self._pdf_doc = QPdfDocument(self._owner)
|
||||
self._pdf_view = QPdfView(self._owner)
|
||||
self._pdf_view.setDocument(self._pdf_doc)
|
||||
try:
|
||||
self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
except Exception: # noqa: BLE001 - enum names vary slightly across versions
|
||||
pass
|
||||
self._owner.stack.addWidget(self._pdf_view)
|
||||
return self._pdf_view
|
||||
|
||||
def _render_pdf(self, pdf_path: str) -> None:
|
||||
view = self._ensure_pdf_view()
|
||||
if view is None:
|
||||
self.show_document_text(pdf_path)
|
||||
return
|
||||
self._pdf_doc.load(pdf_path)
|
||||
self._owner.stack.setCurrentWidget(view)
|
||||
|
||||
def show_document_text(self, path: str) -> None:
|
||||
from cowork_local.core.doc_extract import extract_text
|
||||
o = self._owner
|
||||
try:
|
||||
text, note = extract_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text, note = None, str(exc)
|
||||
body = text if text else tr("folder.doc_unreadable", note=note or "?")
|
||||
o.doc_view.setPlainText(body)
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
|
||||
def write_pptx(self, content: str, skip_confirm: bool = False) -> bool:
|
||||
"""Write edited pptx text back into the deck. If the edit REPLACES any
|
||||
image, ask the user to confirm first. ``skip_confirm`` is used when
|
||||
the image was already confirmed (e.g. just generated). Returns False
|
||||
if the user declined."""
|
||||
from cowork_local.core import pptx_edit
|
||||
o = self._owner
|
||||
if not skip_confirm and pptx_edit.image_change_requested(content):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm"))
|
||||
if ok != QMessageBox.Yes:
|
||||
o.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return False
|
||||
pptx_edit.apply_text_to_pptx(o.current_file, content)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["OfficeDocumentRenderer", "HAS_PDF"]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""WorkspaceFileTree — the folder-picker bar + directory tree pane of the
|
||||
Folder Explorer (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 264-293/386-408 of the original 1587-line file).
|
||||
|
||||
Owns navigation only: which root is browsed and which file was clicked.
|
||||
Rendering/editing the SELECTED file is
|
||||
``document_preview_manager.py::DocumentPreviewManager``'s job — this widget
|
||||
just emits :attr:`file_selected`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog, QFileSystemModel, QHBoxLayout, QLabel, QPushButton,
|
||||
QTreeView, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class WorkspaceFileTree(QWidget):
|
||||
"""The left-hand tree pane: a path bar (label + "open folder" button)
|
||||
above a ``QFileSystemModel``-backed ``QTreeView``."""
|
||||
|
||||
file_selected = Signal(str) # absolute path of the clicked file
|
||||
root_changed = Signal(str) # absolute path of the new root
|
||||
|
||||
def __init__(self, initial_root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = initial_root
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# The path IS the title of this screen, so it is written as one
|
||||
# rather than shown in a read-only text box that looks editable.
|
||||
# Full path on hover; the button still opens the folder picker.
|
||||
bar = QHBoxLayout()
|
||||
self.path_lbl = QLabel(self._root)
|
||||
self.path_lbl.setObjectName("folderTitle")
|
||||
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_lbl.setToolTip(self._root)
|
||||
self._open_btn = QPushButton()
|
||||
self._open_btn.setIcon(icon("folder"))
|
||||
self._open_btn.setObjectName("primary")
|
||||
self._open_btn.clicked.connect(self._pick_root)
|
||||
bar.addWidget(self.path_lbl, 1)
|
||||
bar.addWidget(self._open_btn)
|
||||
root_layout.addLayout(bar)
|
||||
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setRootPath(self._root)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setRootIndex(self.model.index(self._root))
|
||||
for col in (1, 2, 3): # hide Size / Type / Date-modified columns
|
||||
self.tree.hideColumn(col)
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.clicked.connect(self._on_tree_clicked)
|
||||
root_layout.addWidget(self.tree, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._open_btn.setToolTip(tr("folder.path_placeholder"))
|
||||
self._open_btn.setText(tr("folder.open_folder"))
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, path: str) -> None:
|
||||
p = str(path or "").strip()
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
self._root = p
|
||||
self.path_lbl.setText(p)
|
||||
self.path_lbl.setToolTip(p)
|
||||
self.model.setRootPath(p)
|
||||
self.tree.setRootIndex(self.model.index(p))
|
||||
self.root_changed.emit(p)
|
||||
|
||||
def _pick_root(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
|
||||
if chosen:
|
||||
self.set_root(chosen)
|
||||
|
||||
def _on_tree_clicked(self, index) -> None:
|
||||
path = self.model.filePath(index)
|
||||
if path and os.path.isfile(path):
|
||||
self.file_selected.emit(path)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceFileTree"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""GraphRAG (Structure) screen, split into single-responsibility widgets
|
||||
(R08-T14): ``graph_scene_items``, ``graph_renderer``, ``graph_qa_widget``,
|
||||
assembled by the ``structure_graph_view`` shell."""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""GraphMessagesView — the "Messages by day" tab of GraphRAG (R08-T14, split
|
||||
out of ``graph_renderer.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/structure_graph_view.py``, lines 427-497 of the original
|
||||
1035-line file: ``_on_view_tab``, ``_toggle_messages``, ``_reload_messages``,
|
||||
``_show_msg_json``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``GraphRenderer`` — same
|
||||
composition-to-respect-the-line-cap pattern as
|
||||
``office_document_renderer.py``. Owns the ``QTreeWidget`` itself (built
|
||||
here, added to the owner's stack at construction) since nothing else needs
|
||||
it, but reaches into ``owner._stack``/``owner.web``/``owner.view``/
|
||||
``owner.active_project_id`` to switch the shared stack and scope by project.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphMessagesView:
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self.widget = QTreeWidget()
|
||||
self.widget.setHeaderHidden(True)
|
||||
self.widget.itemClicked.connect(self._show_msg_json)
|
||||
owner._stack.addWidget(self.widget)
|
||||
|
||||
def on_view_tab(self, index: int) -> None:
|
||||
"""Tab 0 = graph, tab 1 = messages."""
|
||||
o = self._owner
|
||||
if index == 1:
|
||||
self.reload()
|
||||
o._stack.setCurrentWidget(self.widget)
|
||||
else:
|
||||
o._stack.setCurrentWidget(o.web if o.web is not None else o.view)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
o = self._owner
|
||||
showing = o._stack.currentWidget() is self.widget
|
||||
o.view_tabs.setCurrentIndex(0 if showing else 1)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Build the tree: day -> conversation. Click a conversation to see
|
||||
its messages as JSON. Scoped to the current project's history."""
|
||||
from cowork_local.core.history import list_conversations
|
||||
|
||||
o = self._owner
|
||||
self.widget.clear()
|
||||
pid = o.active_project_id or ""
|
||||
by_day: "OrderedDict[str, list]" = OrderedDict()
|
||||
try:
|
||||
convs = list_conversations(o.ctx.config.history_dir())
|
||||
except Exception: # noqa: BLE001
|
||||
convs = []
|
||||
for conv in convs:
|
||||
if pid and conv.get("project_id", "default") != pid:
|
||||
continue
|
||||
day = (conv.get("created") or "")[:10] or "—"
|
||||
by_day.setdefault(day, []).append(conv)
|
||||
if not by_day:
|
||||
self.widget.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")]))
|
||||
return
|
||||
for day in sorted(by_day, reverse=True):
|
||||
convs_d = by_day[day]
|
||||
day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"])
|
||||
for conv in convs_d:
|
||||
it = QTreeWidgetItem([conv.get("title", "(untitled)")])
|
||||
it.setData(0, Qt.UserRole, str(conv.get("path", "")))
|
||||
day_item.addChild(it)
|
||||
self.widget.addTopLevelItem(day_item)
|
||||
day_item.setExpanded(True)
|
||||
|
||||
def _show_msg_json(self, item, _col: int = 0) -> None:
|
||||
import html
|
||||
import json
|
||||
|
||||
from cowork_local.core.history import load_conversation
|
||||
path = item.data(0, Qt.UserRole)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
conv = load_conversation(path)
|
||||
payload = {"title": conv.get("title", ""), "created": conv.get("created", ""),
|
||||
"kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""),
|
||||
"messages": conv.get("messages", [])}
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"(could not read: {exc})"
|
||||
self._owner.raw_json_ready.emit(
|
||||
f'<pre style="white-space:pre-wrap; font-family:Consolas,monospace; '
|
||||
f'font-size:12px;">{html.escape(text)}</pre>')
|
||||
|
||||
|
||||
__all__ = ["GraphMessagesView"]
|
||||
@@ -0,0 +1,374 @@
|
||||
"""GraphQaWidget — the right-side "ask questions about this graph" panel of
|
||||
GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 288-334/342-360
|
||||
(partial)/647-663/704-955 of the original 1035-line file).
|
||||
|
||||
Reads the current graph and scene selection from a
|
||||
``graph_renderer.py::GraphRenderer`` instance passed at construction
|
||||
(``renderer.graph``, ``renderer.selected_node_data()``,
|
||||
``renderer.active_project_id``) and reacts to its
|
||||
``node_selected``/``graph_rendered``/``raw_json_ready`` signals — this class
|
||||
has no rendering state of its own, matching how
|
||||
``presentation/folder/ai_file_editor_dialog.py`` reads
|
||||
``DocumentPreviewManager`` rather than duplicating file state.
|
||||
|
||||
File-content extraction for grounding the answer goes through
|
||||
``application/workspaces/graph_index_service.py`` (R08-T14 also moved that
|
||||
out of this file, as pure Python — see its own docstring).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QUrl, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import collapse_right_icon, icon
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
|
||||
class GraphQaWidget(QWidget):
|
||||
"""The collapsible pane itself (strip + header + ask row + detail
|
||||
browser) — the shell adds ONE widget to its splitter."""
|
||||
|
||||
status_message = Signal(str)
|
||||
collapse_changed = Signal(bool) # so the shell can resize its own splitter
|
||||
|
||||
def __init__(self, ctx, renderer, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._renderer = renderer
|
||||
self._ask_worker: Optional[AgentWorker] = None
|
||||
self._answer = ""
|
||||
self._detail_mode = "idle" # "answer" | "node" | "idle"
|
||||
self._extract_cache: dict = {}
|
||||
self._extract_dir = None
|
||||
|
||||
outer = QHBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
self._strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
|
||||
self._strip.clicked.connect(lambda: self._set_collapsed(False))
|
||||
self._strip.setVisible(False)
|
||||
outer.addWidget(self._strip)
|
||||
|
||||
self._panel = QWidget()
|
||||
rl = QVBoxLayout(self._panel)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
ag_hdr = QHBoxLayout()
|
||||
self._collapse_btn = QPushButton()
|
||||
self._collapse_btn.setIcon(collapse_right_icon())
|
||||
self._collapse_btn.setFixedWidth(28)
|
||||
self._collapse_btn.clicked.connect(lambda: self._set_collapsed(True))
|
||||
self._label = QLabel()
|
||||
ag_hdr.addWidget(self._collapse_btn)
|
||||
ag_hdr.addWidget(self._label, 1)
|
||||
rl.addLayout(ag_hdr)
|
||||
|
||||
ask_row = QHBoxLayout()
|
||||
self.ask_edit = QLineEdit()
|
||||
self.ask_edit.returnPressed.connect(self._ask)
|
||||
self._ask_btn = QPushButton()
|
||||
self._ask_btn.setIcon(icon("chat"))
|
||||
self._ask_btn.setObjectName("primary")
|
||||
self._ask_btn.clicked.connect(self._ask)
|
||||
ask_row.addWidget(self.ask_edit, 1)
|
||||
ask_row.addWidget(self._ask_btn)
|
||||
rl.addLayout(ask_row)
|
||||
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setReadOnly(True)
|
||||
self.detail.setOpenLinks(False)
|
||||
self.detail.anchorClicked.connect(self._on_detail_link)
|
||||
rl.addWidget(self.detail, 1)
|
||||
outer.addWidget(self._panel, 1)
|
||||
|
||||
renderer.node_selected.connect(self._on_node_selected)
|
||||
renderer.graph_rendered.connect(self._preserve_answer)
|
||||
renderer.raw_json_ready.connect(self._show_raw_json)
|
||||
renderer.project_changed.connect(self.clear_extracts)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip"))
|
||||
self._label.setText(tr("structure.agent_header"))
|
||||
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
|
||||
self._ask_btn.setText(tr("structure.ask"))
|
||||
if self._detail_mode == "idle":
|
||||
self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
|
||||
self._strip.setToolTip(tr("structure.expand_agent_tooltip"))
|
||||
|
||||
# ---- collapse ------------------------------------------------------------- #
|
||||
def _set_collapsed(self, collapsed: bool) -> None:
|
||||
self._panel.setVisible(not collapsed)
|
||||
self._strip.setVisible(collapsed)
|
||||
self.collapse_changed.emit(collapsed)
|
||||
|
||||
# ---- reacting to the renderer ----------------------------------------------- #
|
||||
def _on_node_selected(self, data) -> None:
|
||||
self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}")
|
||||
self._detail_mode = "node"
|
||||
|
||||
def _show_raw_json(self, html_text: str) -> None:
|
||||
self.detail.setHtml(html_text)
|
||||
|
||||
def _preserve_answer(self) -> None:
|
||||
if self._detail_mode == "answer" and self._answer.strip():
|
||||
self._render_answer()
|
||||
|
||||
# ---- Q&A -------------------------------------------------------------------- #
|
||||
@staticmethod
|
||||
def _graph_context(graph) -> str:
|
||||
from collections import defaultdict
|
||||
by_kind = defaultdict(list)
|
||||
for n in graph.nodes:
|
||||
by_kind[n.kind].append(n.label)
|
||||
lines = []
|
||||
for kind in ("file", "class", "function", "method", "module", "section"):
|
||||
items = by_kind.get(kind, [])
|
||||
if items:
|
||||
lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
|
||||
id2label = {n.id: n.label for n in graph.nodes}
|
||||
rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
|
||||
for e in graph.edges[:140]]
|
||||
if rels:
|
||||
lines.append("Relationships (sample):\n" + "\n".join(rels))
|
||||
return "\n".join(lines)[:7000]
|
||||
|
||||
def _matched_sources(self, text: str):
|
||||
graph = self._renderer.graph
|
||||
if graph is None or not text:
|
||||
return []
|
||||
found: dict = {}
|
||||
for n in graph.nodes:
|
||||
if not n.path:
|
||||
continue
|
||||
label = n.label.rstrip("()")
|
||||
if len(label) < 3:
|
||||
continue
|
||||
if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
|
||||
found[n.path] = (n.kind, n.label, n.detail or n.path)
|
||||
return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
|
||||
|
||||
def _linkify_files(self, text: str, sources) -> str:
|
||||
"""Turn file/entity NAMES mentioned in the answer into clickable
|
||||
links that open the file."""
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
tokens = []
|
||||
base = Path(path).name
|
||||
if base and len(base) >= 3:
|
||||
tokens.append(base)
|
||||
lab = (label or "").rstrip("()").strip()
|
||||
if lab and lab != base and len(lab) >= 3:
|
||||
tokens.append(lab)
|
||||
for tok in tokens:
|
||||
esc = re.escape(tok)
|
||||
text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
|
||||
text = re.sub(rf"(?<![\w`/\\.\]\)]){esc}(?![\w`\]\(])", f"[{tok}]({href})", text)
|
||||
return text
|
||||
|
||||
def _render_answer(self) -> None:
|
||||
text = self._answer
|
||||
sources = self._matched_sources(text)
|
||||
if sources:
|
||||
text = self._linkify_files(text, sources)
|
||||
lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
|
||||
lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
|
||||
text = "\n".join(lines)
|
||||
self.detail.setMarkdown(text)
|
||||
|
||||
def _on_detail_link(self, url: QUrl) -> None:
|
||||
if url.isLocalFile():
|
||||
p = url.toLocalFile()
|
||||
if Path(p).is_file():
|
||||
open_location(p)
|
||||
else:
|
||||
open_folder(p)
|
||||
|
||||
def _ask(self) -> None:
|
||||
question = self.ask_edit.text().strip()
|
||||
if not question:
|
||||
return
|
||||
from cowork_local.core.skills import parse_skill_command
|
||||
skill_prefix, question, info = parse_skill_command(question)
|
||||
if info is not None:
|
||||
self.detail.setMarkdown(info)
|
||||
self._detail_mode = "answer"
|
||||
self.ask_edit.clear()
|
||||
return
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
self.status_message.emit(tr("structure.scan_first"))
|
||||
return
|
||||
context = self._graph_context(graph)
|
||||
file_paths = self._candidate_file_paths()
|
||||
extract_cache = dict(self._extract_cache)
|
||||
extract_dir = str(self._extract_tmp_dir())
|
||||
self._answer = ""
|
||||
self._detail_mode = "answer"
|
||||
self.detail.setPlainText("…")
|
||||
self.ask_edit.clear()
|
||||
|
||||
active_project_id = self._renderer.active_project_id
|
||||
selected_nodes = self._renderer.selected_node_data()
|
||||
selected_context = self._selection_context(selected_nodes, graph)
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
provider = self.ctx.build_active_provider()
|
||||
system = self._system_prompt(skill_prefix, active_project_id)
|
||||
user_content = f"Graph context:\n{context}"
|
||||
if selected_context:
|
||||
user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
|
||||
content_block, new_cache = extract_file_contents(file_paths, extract_cache, extract_dir)
|
||||
if content_block:
|
||||
user_content += ("\n\nExtracted file contents (read these to answer about file "
|
||||
"details/data; cite the file path):\n" + content_block)
|
||||
user_content += f"\n\nQuestion: {question}"
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
from cowork_local.core import agent_roles, audit_log
|
||||
ok = True
|
||||
try:
|
||||
provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
|
||||
cancel=worker.is_cancelled)
|
||||
except Exception:
|
||||
ok = False
|
||||
raise
|
||||
finally:
|
||||
audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
|
||||
agent_role=agent_roles.KNOWLEDGE)
|
||||
return {"extracted": new_cache}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.event.connect(self._on_ask_event)
|
||||
w.finished_ok.connect(self._on_ask_done)
|
||||
w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
|
||||
self._ask_worker = w
|
||||
w.start()
|
||||
|
||||
@staticmethod
|
||||
def _selection_context(selected_nodes, graph) -> str:
|
||||
if not selected_nodes:
|
||||
return ""
|
||||
node_lines = []
|
||||
for nd in selected_nodes:
|
||||
node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
|
||||
if nd.detail:
|
||||
node_lines.append(f" detail: {nd.detail}")
|
||||
connected_ids = set()
|
||||
for nd in selected_nodes:
|
||||
for edge in graph.edges:
|
||||
if edge.source == nd.id:
|
||||
connected_ids.add(edge.target)
|
||||
elif edge.target == nd.id:
|
||||
connected_ids.add(edge.source)
|
||||
connected_nodes = [n for n in graph.nodes if n.id in connected_ids]
|
||||
if connected_nodes:
|
||||
node_lines.append("\nConnected nodes:")
|
||||
for cn in connected_nodes:
|
||||
node_lines.append(f"- {cn.label} (kind: {cn.kind})")
|
||||
return "\n".join(node_lines)
|
||||
|
||||
@staticmethod
|
||||
def _system_prompt(skill_prefix: str, active_project_id: str) -> str:
|
||||
system = ("You answer questions about a code/document knowledge graph. Use the provided "
|
||||
"graph context AND the extracted file contents to retrieve, synthesize and "
|
||||
"explain the answer. Be concise. Answer ONLY from what is provided (graph "
|
||||
"context + extracted contents) — never invent files, functions, or facts that "
|
||||
"aren't in it.\n\n"
|
||||
"EACH answer MUST include source citations so the user can verify where "
|
||||
"information came from. For every factual claim, file reference, or code "
|
||||
"element you mention, add a citation using this format:\n\n"
|
||||
" [source: filename.ext, line/section: XXX]\n\n"
|
||||
"Rules for citations:\n"
|
||||
" 1. Cite the EXACT file path from the graph context (use the path field).\n"
|
||||
" 2. For Python files: cite the function/class name and approximate line "
|
||||
" if available, or the module name.\n"
|
||||
" 3. For document files (.md, .txt): cite the section heading.\n"
|
||||
" 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
|
||||
" 5. Place citations inline after the relevant sentence or fact.\n"
|
||||
" 6. At the end of your answer, add a '---' separator followed by a "
|
||||
" numbered **Sources cited:** section listing each unique source with "
|
||||
" its full path so the user can click to open it.\n\n"
|
||||
"Example citation format in text:\n"
|
||||
" The `process_data()` function handles CSV parsing "
|
||||
"[source: src/utils/parser.py, function: process_data].\n\n"
|
||||
"Example end-of-answer source list:\n"
|
||||
" ---\n"
|
||||
" **Sources cited:**\n"
|
||||
" 1. `src/utils/parser.py` — process_data function\n"
|
||||
" 2. `docs/api.md` — Section: Authentication\n")
|
||||
if skill_prefix:
|
||||
system += "\n\nFollow this skill:\n" + skill_prefix
|
||||
if active_project_id:
|
||||
from cowork_local.core.projects import load_project, project_context_text
|
||||
proj_ctx = project_context_text(load_project(active_project_id))
|
||||
if proj_ctx:
|
||||
system += "\n\n" + proj_ctx
|
||||
return system
|
||||
|
||||
def _on_ask_event(self, ev: dict) -> None:
|
||||
if ev.get("type") == "text":
|
||||
if self._answer == "":
|
||||
self.detail.clear()
|
||||
self._answer += ev.get("delta", "")
|
||||
self.detail.setPlainText(self._answer)
|
||||
|
||||
def _on_ask_done(self, result: dict) -> None:
|
||||
# Keep the (temporary) extracted content so repeated questions reuse
|
||||
# it without re-extracting — dropped when leaving the tab.
|
||||
if isinstance(result, dict):
|
||||
self._extract_cache.update(result.get("extracted", {}) or {})
|
||||
self._render_answer()
|
||||
|
||||
# ---- temporary file-content extraction for Q&A -------------------------------- #
|
||||
def _candidate_file_paths(self) -> List[str]:
|
||||
"""File paths to read for a question: the SELECTED file nodes if
|
||||
any, else every file node in the graph (capped downstream)."""
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
return []
|
||||
sel = self._renderer.selected_node_data()
|
||||
nodes = sel or list(graph.nodes)
|
||||
out, seen = [], set()
|
||||
for nd in nodes:
|
||||
p = (getattr(nd, "path", "") or "").strip()
|
||||
if p and p not in seen and Path(p).is_file():
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _extract_tmp_dir(self) -> Path:
|
||||
if self._extract_dir is None:
|
||||
import tempfile
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
base = CONFIG_DIR / "tmp" / "graphrag_extract"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
self._extract_dir = Path(tempfile.mkdtemp(dir=str(base)))
|
||||
return self._extract_dir
|
||||
|
||||
def clear_extracts(self) -> None:
|
||||
"""Discard the temporary extracted content (on leaving the tab /
|
||||
switching project). The extraction is a scratch aid, never
|
||||
persisted."""
|
||||
self._extract_cache = {}
|
||||
d, self._extract_dir = self._extract_dir, None
|
||||
if d is not None:
|
||||
import shutil
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = ["GraphQaWidget"]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""GraphRenderer — the toolbar, scan/render pipeline, and graph/messages
|
||||
stack of GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 188-286/336-661/
|
||||
664-702 of the original 1035-line file — everything except the right-side
|
||||
Q&A panel, which is ``graph_qa_widget.py::GraphQaWidget``).
|
||||
|
||||
Talks to the Q&A panel only through signals (:attr:`node_selected`,
|
||||
:attr:`graph_rendered`) and a small read API (:attr:`graph`,
|
||||
:meth:`selected_node_data`, :attr:`active_project_id`) — this class has no
|
||||
idea ``GraphQaWidget`` exists, matching how
|
||||
``presentation/folder/document_preview_manager.py`` doesn't know about the
|
||||
AI-edit panel either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFileDialog, QGraphicsScene, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QStackedWidget, QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
graph_rendered = Signal() # a scan just finished rendering (fresh OR re-fit)
|
||||
raw_json_ready = Signal(str) # pre-formatted HTML for a clicked Messages entry
|
||||
project_changed = Signal() # a DIFFERENT project was selected (or cleared)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self._node_items: List[_Node] = []
|
||||
self._edge_items: List[_Edge] = []
|
||||
self._centroid = QPointF(0, 0)
|
||||
self._graph = None
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
self._rescan_timer.setInterval(1500)
|
||||
self._rescan_timer.timeout.connect(self._scan)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir()))
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn = QPushButton()
|
||||
self._pick_btn.setIcon(icon("folder"))
|
||||
self._pick_btn.setObjectName("primary")
|
||||
self._pick_btn.clicked.connect(self._pick)
|
||||
self.project_combo = QComboBox()
|
||||
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
|
||||
self._scan_btn = QPushButton()
|
||||
self._scan_btn.setIcon(icon("search"))
|
||||
self._scan_btn.setObjectName("primary")
|
||||
self._scan_btn.clicked.connect(self._scan)
|
||||
self._export_btn = QPushButton()
|
||||
self._export_btn.setIcon(icon("upload"))
|
||||
self._export_btn.setObjectName("primary")
|
||||
self._export_btn.clicked.connect(self._export)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self._pick_btn)
|
||||
bar.addWidget(self.project_combo)
|
||||
bar.addWidget(self._scan_btn)
|
||||
bar.addWidget(self._export_btn)
|
||||
root.addLayout(bar)
|
||||
self._refresh_project_combo()
|
||||
|
||||
# Đồ thị | Tin nhắn as a real pair of tabs.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
self.view_tabs.addTab(icon("graph"), "")
|
||||
self.view_tabs.addTab(icon("message"), "")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_tab)
|
||||
tab_row = QHBoxLayout()
|
||||
tab_row.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row.addWidget(self.view_tabs)
|
||||
tab_row.addStretch(1)
|
||||
root.addLayout(tab_row)
|
||||
|
||||
self.scene = QGraphicsScene()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg))
|
||||
self.scene.selectionChanged.connect(self._on_selection)
|
||||
self.view = _GraphView(self.scene)
|
||||
|
||||
self._stack = QStackedWidget()
|
||||
self._stack.addWidget(self.view)
|
||||
self.web = None
|
||||
self._bridge = None
|
||||
self._channel = None
|
||||
root.addWidget(self._stack, 1)
|
||||
# "Messages" view: all conversation messages grouped BY DAY, shown as
|
||||
# JSON — a separate concern composed in (see graph_messages_view.py).
|
||||
self._messages = GraphMessagesView(self)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn.setText(tr("structure.browse"))
|
||||
self._scan_btn.setText(tr("structure.scan"))
|
||||
self._export_btn.setText(tr("structure.export_png"))
|
||||
self.view_tabs.setTabText(0, tr("structure.graph_btn"))
|
||||
self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
|
||||
self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
|
||||
self.project_combo.setToolTip(tr("structure.project_tooltip"))
|
||||
self._refresh_project_combo()
|
||||
|
||||
# ---- public read API for GraphQaWidget ----------------------------------- #
|
||||
@property
|
||||
def graph(self):
|
||||
return self._graph
|
||||
|
||||
@property
|
||||
def active_project_id(self) -> str:
|
||||
return self._active_project_id
|
||||
|
||||
def selected_node_data(self) -> list:
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
|
||||
if chosen:
|
||||
self.path_edit.setText(chosen)
|
||||
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
if self._graph is None:
|
||||
self._needs_scan = True
|
||||
return
|
||||
self._rescan_timer.start()
|
||||
|
||||
# ---- Messages (by day, as JSON) — see graph_messages_view.py --------------- #
|
||||
def _on_view_tab(self, index: int) -> None:
|
||||
self._messages.on_view_tab(index)
|
||||
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
self._messages.toggle()
|
||||
|
||||
# ---- prewarm / scan lifecycle -------------------------------------------------- #
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during."""
|
||||
if not HAS_WEB_ENGINE or self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
def _ensure_web(self) -> None:
|
||||
if self.web is not None or not HAS_WEB_ENGINE:
|
||||
return
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
self.web = QWebEngineView()
|
||||
self.web.setHtml(
|
||||
f"<body style='margin:0;background:{current_palette().bg}'></body>")
|
||||
self._bridge = _Bridge()
|
||||
self._channel = QWebChannel()
|
||||
self._channel.registerObject("py", self._bridge)
|
||||
self.web.page().setWebChannel(self._channel)
|
||||
self._stack.addWidget(self.web)
|
||||
self._stack.setCurrentWidget(self.web)
|
||||
if self._graph is not None:
|
||||
self._render_d3()
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self._ensure_web()
|
||||
if not self.path_edit.text().strip():
|
||||
return
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
if self._graph is not None and not self._needs_scan:
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- scan --------------------------------------------------------------------- #
|
||||
def _scan(self) -> None:
|
||||
path = self.path_edit.text().strip() or str(Path.cwd())
|
||||
mode = "files"
|
||||
use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
|
||||
cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
|
||||
st = self.ctx.config.structure
|
||||
max_nodes = int(st.get("max_nodes", 500) or 0)
|
||||
max_edges = int(st.get("max_edges", 500) or 0)
|
||||
self._scan_seq += 1
|
||||
seq = self._scan_seq
|
||||
self.status_message.emit(tr("structure.scanning"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from cowork_local.core.structure_graph import (
|
||||
build_from_codebase_memory, build_from_directory, force_layout,
|
||||
)
|
||||
if use_cmem:
|
||||
from cowork_local.core.codebase_memory import CodebaseMemory
|
||||
mem = CodebaseMemory(cmem_bin)
|
||||
graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
|
||||
if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
|
||||
else:
|
||||
graph = build_from_directory(path, mode, max_nodes, max_edges)
|
||||
pos = force_layout(graph)
|
||||
return {"graph": graph, "pos": pos, "seq": seq}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._render)
|
||||
w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _render(self, result: dict) -> None:
|
||||
if result.get("seq") is not None and result["seq"] != self._scan_seq:
|
||||
return
|
||||
graph = result.get("graph")
|
||||
pos = result.get("pos", {})
|
||||
if graph is None:
|
||||
return
|
||||
self._graph = graph
|
||||
|
||||
self.scene.clear()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg))
|
||||
self._node_items = []
|
||||
self._edge_items = []
|
||||
degree = {n.id: 0 for n in graph.nodes}
|
||||
for e in graph.edges:
|
||||
if e.source in degree:
|
||||
degree[e.source] += 1
|
||||
if e.target in degree:
|
||||
degree[e.target] += 1
|
||||
items = {}
|
||||
sx = sy = 0.0
|
||||
for node in graph.nodes:
|
||||
radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
|
||||
item = _Node(node, radius)
|
||||
x, y = pos.get(node.id, (0, 0))
|
||||
item.setPos(x, y)
|
||||
self.scene.addItem(item)
|
||||
items[node.id] = item
|
||||
self._node_items.append(item)
|
||||
sx += x
|
||||
sy += y
|
||||
for edge in graph.edges:
|
||||
a, b = items.get(edge.source), items.get(edge.target)
|
||||
if a and b:
|
||||
e = _Edge(a, b, getattr(edge, "type", ""))
|
||||
self.scene.addItem(e)
|
||||
self._edge_items.append(e)
|
||||
n = max(1, len(self._node_items))
|
||||
self._centroid = QPointF(sx / n, sy / n)
|
||||
self._fit()
|
||||
|
||||
if self.web is not None:
|
||||
self._render_d3()
|
||||
|
||||
note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
|
||||
self.status_message.emit(tr(
|
||||
"structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
|
||||
self.graph_rendered.emit()
|
||||
|
||||
def _render_d3(self) -> None:
|
||||
if self.web is None or self._graph is None:
|
||||
return
|
||||
from cowork_local.core.d3_graph import build_html
|
||||
try:
|
||||
self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
|
||||
except Exception as exc:
|
||||
self.status_message.emit(f"D3 view error: {exc}")
|
||||
|
||||
# ---- native interactions ------------------------------------------------------- #
|
||||
def _on_selection(self) -> None:
|
||||
for item in self.scene.selectedItems():
|
||||
if isinstance(item, _Node):
|
||||
self.node_selected.emit(item.data)
|
||||
return
|
||||
|
||||
def _fit(self) -> None:
|
||||
if self.web is not None and self._stack.currentWidget() is self.web:
|
||||
self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
|
||||
return
|
||||
rect = self.scene.itemsBoundingRect()
|
||||
if not rect.isNull():
|
||||
self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
|
||||
def _export(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
|
||||
if not path:
|
||||
return
|
||||
showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
|
||||
if showing_d3:
|
||||
self._export_d3_png(path)
|
||||
else:
|
||||
self._export_widget_grab(path)
|
||||
|
||||
def _export_d3_png(self, path: str) -> None:
|
||||
def on_result(data_url) -> None:
|
||||
if not isinstance(data_url, str) or "," not in data_url:
|
||||
self._export_widget_grab(path)
|
||||
return
|
||||
import base64
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(data_url.split(",", 1)[1]))
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
self.status_message.emit(tr("structure.export_failed", err=str(exc)))
|
||||
self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
|
||||
|
||||
def _export_widget_grab(self, path: str) -> None:
|
||||
ok = self._stack.currentWidget().grab().save(path, "PNG")
|
||||
if ok:
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
else:
|
||||
self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
|
||||
|
||||
|
||||
__all__ = ["GraphRenderer"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Native QGraphicsScene primitives for the fallback (non-WebEngine) graph
|
||||
view (R08-T14, split out of ``graph_renderer.py`` to keep it under the
|
||||
400-line cap; originally ``ui/structure_graph_view.py``, lines 65-186 of the
|
||||
original 1035-line file: ``_Bridge``, ``_Edge``, ``_Node``, ``_GraphView``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import QObject, QPointF, Qt, Slot
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView
|
||||
|
||||
from cowork_local.core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Exposed to the D3 page so a Shift+click on a node can open its
|
||||
storage folder/link (local path or URL — see osutil.open_location)."""
|
||||
|
||||
@Slot(str)
|
||||
def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
|
||||
if path:
|
||||
open_location(path)
|
||||
|
||||
|
||||
class _Edge(QGraphicsLineItem):
|
||||
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
|
||||
super().__init__()
|
||||
self.a, self.b = a, b
|
||||
self.type = type_
|
||||
# Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
|
||||
# so the graph shows what each connection MEANS — falling back to the
|
||||
# source node's tint for any untyped edge.
|
||||
color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
|
||||
if not color.isValid():
|
||||
color = a.brush().color().lighter(130)
|
||||
self._color = color
|
||||
self.setPen(QPen(color, 1.4))
|
||||
self.setZValue(-1)
|
||||
# A small label naming the relationship, shown at the edge midpoint.
|
||||
self._label = None
|
||||
if type_:
|
||||
self._label = QGraphicsSimpleTextItem(type_, self)
|
||||
self._label.setBrush(QBrush(color.lighter(140)))
|
||||
f = QFont()
|
||||
f.setPointSize(7)
|
||||
self._label.setFont(f)
|
||||
self._label.setZValue(0)
|
||||
a.edges.append(self)
|
||||
b.edges.append(self)
|
||||
self.adjust()
|
||||
|
||||
def adjust(self) -> None:
|
||||
pa, pb = self.a.scenePos(), self.b.scenePos()
|
||||
self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
|
||||
if self._label is not None:
|
||||
br = self._label.boundingRect()
|
||||
self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
|
||||
(pa.y() + pb.y()) / 2 - br.height() / 2)
|
||||
|
||||
|
||||
class _Node(QGraphicsEllipseItem):
|
||||
def __init__(self, data, radius: int):
|
||||
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
|
||||
self.data = data
|
||||
self.edges = []
|
||||
tok = current_palette()
|
||||
# NODE_KIND_COLORS is a categorical data encoding (one hue per node
|
||||
# kind), not UI chrome — it stays fixed across themes on purpose so a
|
||||
# given kind is always the same colour. Only the chrome follows tokens.
|
||||
color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
|
||||
self.setBrush(QBrush(color))
|
||||
self.setPen(QPen(color.darker(160), 1.5))
|
||||
self.setFlags(
|
||||
QGraphicsEllipseItem.ItemIsMovable
|
||||
| QGraphicsEllipseItem.ItemIsSelectable
|
||||
| QGraphicsEllipseItem.ItemSendsGeometryChanges
|
||||
)
|
||||
self.setZValue(1)
|
||||
label = QGraphicsSimpleTextItem(data.label, self)
|
||||
label.setBrush(QBrush(QColor(tok.text)))
|
||||
label.setPos(radius + 3, -8)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
|
||||
for edge in self.edges:
|
||||
edge.adjust()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class _GraphView(QGraphicsView):
|
||||
def __init__(self, scene):
|
||||
super().__init__(scene)
|
||||
self.setDragMode(QGraphicsView.NoDrag)
|
||||
self._panning = False
|
||||
self._pan_start = QPointF()
|
||||
|
||||
def wheelEvent(self, e): # noqa: N802
|
||||
self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
|
||||
1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
|
||||
def mousePressEvent(self, e): # noqa: N802
|
||||
if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
|
||||
self._panning = True
|
||||
self._pan_start = e.position()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
delta = e.position() - self._pan_start
|
||||
self._pan_start = e.position()
|
||||
self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
|
||||
self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e): # noqa: N802
|
||||
"""Double-click or Ctrl+click on a node opens its storage folder."""
|
||||
item = self.itemAt(e.pos())
|
||||
if isinstance(item, _Node) and getattr(item.data, "path", ""):
|
||||
open_folder(item.data.path)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(e)
|
||||
|
||||
|
||||
__all__ = ["_Bridge", "_Edge", "_Node", "_GraphView"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""StructureGraphView shell (R08-T14) — assembles
|
||||
``graph_renderer.py::GraphRenderer`` and
|
||||
``graph_qa_widget.py::GraphQaWidget`` behind the splitter that used to be
|
||||
inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines
|
||||
188-343 of the original 1035-line file), and forwards the public methods
|
||||
``app.py``/``ui/workspace_tab.py`` call: ``schedule_rescan``,
|
||||
``auto_scan_and_fit``, ``set_project``, ``prewarm``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.i18n import on_language_changed
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
_COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default
|
||||
|
||||
|
||||
class StructureGraphView(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
self.renderer.status_message.connect(self.status_message.emit)
|
||||
self.qa = GraphQaWidget(ctx, self.renderer)
|
||||
self.qa.status_message.connect(self.status_message.emit)
|
||||
self.qa.collapse_changed.connect(self._on_qa_collapse_changed)
|
||||
|
||||
self._split = QSplitter(Qt.Horizontal)
|
||||
self._split.addWidget(self.renderer)
|
||||
self._split.addWidget(self.qa)
|
||||
self._split.setChildrenCollapsible(False)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
root.addWidget(self._split, 1)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.renderer._retranslate()
|
||||
self.qa.retranslate()
|
||||
|
||||
def _on_qa_collapse_changed(self, collapsed: bool) -> None:
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
if collapsed:
|
||||
self.qa.setMaximumWidth(strip_w)
|
||||
sizes = self._split.sizes()
|
||||
if len(sizes) == 2:
|
||||
self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
|
||||
else:
|
||||
self.qa.setMaximumWidth(16777215)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
|
||||
# ---- public API (app.py / ui/workspace_tab.py) --------------------------- #
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
self.renderer.schedule_rescan(path)
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self.renderer.auto_scan_and_fit()
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
self.renderer.set_project(project_id)
|
||||
|
||||
def prewarm(self) -> None:
|
||||
self.renderer.prewarm()
|
||||
|
||||
def hideEvent(self, e): # noqa: N802
|
||||
# Leaving the GraphRAG tab → drop the temporary extracted info.
|
||||
self.qa.clear_extracts()
|
||||
super().hideEvent(e)
|
||||
|
||||
|
||||
__all__ = ["StructureGraphView"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Schedule Task screen, split into single-responsibility widgets (R08-T11):
|
||||
``kanban_board_widget``, ``calendar_view_widget``, ``ai_task_creator_dialog``,
|
||||
``ai_task_import_dialog``, assembled by the ``schedule_task_tab`` shell."""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""AiTaskCreatorDialog — "AI Create Task" (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog``, lines 579-641/722-794 of
|
||||
the original 795-line file).
|
||||
|
||||
Still one dialog with two tabs (AI-gen, then Import — the latter is
|
||||
:class:`~presentation.scheduling.ai_task_import_dialog.ImportTaskPanel`,
|
||||
embedded here rather than duplicated): the physical file split matches
|
||||
``docs/refactor/Feature_Architecture_Proposal.md``'s R08-T11 breakdown, the
|
||||
user-visible dialog is unchanged. ``_confirm`` still uses "whichever tab
|
||||
produced a task list most recently" (mirroring the original class's shared
|
||||
``self._planned`` attribute) — the AI-gen tab sets it on completion, the
|
||||
Import tab reports it through :attr:`ImportTaskPanel.tasks_changed`.
|
||||
|
||||
AI generation goes through
|
||||
``application/scheduling/ai_task_planner_service.py::AiTaskPlannerService``
|
||||
(R07-T05) instead of ``core.ai_task_planner.plan_tasks`` directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPlainTextEdit, QPushButton, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from cowork_local.core.projects import list_projects
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class AiTaskCreatorDialog(QDialog):
|
||||
"""Create tasks two ways, one tab each (both preview first — nothing is
|
||||
saved until the user confirms): ✨ AI gen from a natural-language
|
||||
description, or 📥 Import from a filled Excel/CSV/JSON file."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider)
|
||||
self.created_tasks: List[dict] = []
|
||||
self._ai_planned: List[dict] = []
|
||||
# Which tab produced the task list currently backing the Ok button —
|
||||
# mirrors the original single-class dialog's shared `self._planned`
|
||||
# attribute, where whichever of _on_planned()/_load_import_file()
|
||||
# ran LAST (regardless of which tab is currently showing) won.
|
||||
self._active_source = "ai"
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||
self.resize(600, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
ws_row = QHBoxLayout()
|
||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||
self.workspace_combo = QComboBox()
|
||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||
for p in list_projects():
|
||||
self.workspace_combo.addItem(p.name, p.project_id)
|
||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||
ws_row.addWidget(self.workspace_combo, 1)
|
||||
root.addLayout(ws_row)
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
self.tabs.addTab(self._build_ai_gen_page(), tr("schedtask.tab_ai"))
|
||||
self.import_panel = ImportTaskPanel(self._planner)
|
||||
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
|
||||
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
# ---- AI-gen tab -------------------------------------------------------
|
||||
def _build_ai_gen_page(self) -> QWidget:
|
||||
ai_page = QWidget()
|
||||
al = QVBoxLayout(ai_page)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||
self.desc_edit = QPlainTextEdit()
|
||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||
self.desc_edit.setMaximumHeight(110)
|
||||
al.addWidget(self.desc_edit)
|
||||
# Attachments (files + links) — merged into every task this generates,
|
||||
# AND into the planning prompt so the AI knows they exist.
|
||||
attach_row = QHBoxLayout()
|
||||
self.ai_files_edit = QLineEdit()
|
||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||
ai_pick_btn.setIcon(icon("folder"))
|
||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||
attach_row.addWidget(self.ai_files_edit, 1)
|
||||
attach_row.addWidget(ai_pick_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||
al.addLayout(attach_row)
|
||||
self.ai_links_edit = QLineEdit()
|
||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||
al.addWidget(self.ai_links_edit)
|
||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setObjectName("primary")
|
||||
self.gen_btn.clicked.connect(self._generate)
|
||||
al.addWidget(self.gen_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
al.addWidget(self.preview, 1)
|
||||
return ai_page
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
if files:
|
||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||
self.ai_files_edit.setText("; ".join(existing + files))
|
||||
|
||||
def _attached_files(self) -> List[str]:
|
||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||
|
||||
def _attached_links(self) -> List[str]:
|
||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||
|
||||
def _generate(self) -> None:
|
||||
description = self.desc_edit.toPlainText().strip()
|
||||
if not description or self._worker is not None:
|
||||
return
|
||||
files, links = self._attached_files(), self._attached_links()
|
||||
self.gen_btn.setEnabled(False)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
full_desc = description
|
||||
if files or links:
|
||||
attach_note = "; ".join(files + links)
|
||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||
planned = self._planner.plan(
|
||||
full_desc, file_paths=files, links=links, cancel=worker.is_cancelled)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._on_planned)
|
||||
w.failed.connect(self._on_failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _on_planned(self, result: dict) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._ai_planned = result.get("tasks") or []
|
||||
self._active_source = "ai"
|
||||
lines = []
|
||||
for i, t in enumerate(self._ai_planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
dep = t.get("dependency", {})
|
||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||
f" {t.get('description', '')[:150]}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
# ---- Import tab ---------------------------------------------------------
|
||||
def _on_import_tasks_changed(self, has_tasks: bool) -> None:
|
||||
if has_tasks:
|
||||
self._active_source = "import"
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(has_tasks)
|
||||
|
||||
# ---- confirm ------------------------------------------------------------
|
||||
def _confirm(self) -> None:
|
||||
planned = self.import_panel.planned if self._active_source == "import" else self._ai_planned
|
||||
project_id = self.workspace_combo.currentData() or ""
|
||||
for t in planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = planned
|
||||
self.accept()
|
||||
|
||||
|
||||
__all__ = ["AiTaskCreatorDialog"]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Import-from-file tab content for AI Create Task (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` — the Import tab + its
|
||||
``_DropZone``, lines 551-576/643-665/674-720 of the original file).
|
||||
|
||||
:class:`ImportTaskPanel` is a plain ``QWidget`` (not its own dialog) so
|
||||
``ai_task_creator_dialog.py`` can embed it as one tab of the single AI-create
|
||||
dialog the user sees — the two files are a code split, not a UX split; there
|
||||
is still one dialog with two tabs, exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setMinimumHeight(70)
|
||||
_p = current_palette()
|
||||
self.setStyleSheet(
|
||||
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
||||
f" border-radius: {_p.radius_lg}px;"
|
||||
f" color: {_p.text_muted}; padding: 10px; }}")
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls and urls[0].toLocalFile().lower().endswith(
|
||||
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_dropped.emit(urls[0].toLocalFile())
|
||||
|
||||
|
||||
class ImportTaskPanel(QWidget):
|
||||
"""Pick/drag an Excel/CSV/JSON file, preview the tasks it maps to, and
|
||||
hold that NOT-yet-saved list — the dialog reads :attr:`planned` when the
|
||||
user confirms.
|
||||
|
||||
Args:
|
||||
planner: an ``AiTaskPlannerService`` — ``import_file`` is called
|
||||
through it (R07-T05) rather than ``core.task_import`` directly.
|
||||
"""
|
||||
|
||||
tasks_changed = Signal(bool) # True when the current preview has >=1 valid task
|
||||
|
||||
def __init__(self, planner: AiTaskPlannerService, parent=None):
|
||||
super().__init__(parent)
|
||||
self._planner = planner
|
||||
self.planned: List[dict] = []
|
||||
|
||||
il = QVBoxLayout(self)
|
||||
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
||||
tpl_btn.setIcon(icon("upload"))
|
||||
tpl_btn.clicked.connect(self._export_template)
|
||||
il.addWidget(tpl_btn)
|
||||
pick_row = QHBoxLayout()
|
||||
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
||||
pick_btn.setIcon(icon("folder"))
|
||||
pick_btn.clicked.connect(self._pick_import_file)
|
||||
pick_row.addWidget(pick_btn)
|
||||
pick_row.addStretch(1)
|
||||
il.addLayout(pick_row)
|
||||
self.drop_zone = _DropZone()
|
||||
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
||||
self.drop_zone.file_dropped.connect(self._load_import_file)
|
||||
il.addWidget(self.drop_zone)
|
||||
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
il.addWidget(self.preview, 1)
|
||||
|
||||
def _export_template(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
export_template(path)
|
||||
open_path(str(Path(path).parent))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||
|
||||
def _pick_import_file(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.core.task_import import IMPORT_FILTER
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
||||
if path:
|
||||
self._load_import_file(path)
|
||||
|
||||
def _load_import_file(self, path: str) -> None:
|
||||
try:
|
||||
self.planned = self._planner.import_file(path)
|
||||
except ValueError as exc:
|
||||
# Same as the original single-class dialog: a bad file leaves
|
||||
# whatever was previously loaded in `planned` untouched (only the
|
||||
# preview text and the Ok button reflect the failure) rather than
|
||||
# discarding a prior successful load.
|
||||
self.preview.setPlainText(str(exc))
|
||||
self.tasks_changed.emit(False)
|
||||
return
|
||||
by_id = {t["task_id"]: t["title"] for t in self.planned}
|
||||
lines = []
|
||||
for i, t in enumerate(self.planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
deps = t.get("dependency", {}).get("depends_on") or []
|
||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.tasks_changed.emit(bool(self.planned))
|
||||
|
||||
|
||||
__all__ = ["ImportTaskPanel"]
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board:
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board
|
||||
(R08-T11, relocated from ``ui/calendar_view.py`` with no logic changes):
|
||||
Week / Month / Year granularity, each task placed on its scheduled date
|
||||
(``schedule.run_at``). Click a task to edit it (same editor the Kanban
|
||||
board's double-click opens); click a day's "+" to create a task pre-filled
|
||||
@@ -16,12 +17,12 @@ from PySide6.QtWidgets import (
|
||||
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.calendar_grid import (
|
||||
from cowork_local.core.calendar_grid import (
|
||||
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
|
||||
)
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||
|
||||
@@ -229,3 +230,6 @@ class CalendarView(QWidget):
|
||||
self.period_lbl.setText(str(self.anchor.year))
|
||||
else:
|
||||
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
|
||||
|
||||
|
||||
__all__ = ["CalendarView"]
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Kanban board for Schedule Task (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``ScheduleTaskTab`` — Kanban rendering +
|
||||
drag-drop + row actions, lines 41-79/222-300/302-484/496-548 of the original
|
||||
795-line file).
|
||||
|
||||
Owns the 7-lane board itself. What used to be plain module-function calls
|
||||
into ``core/tasks.py`` (``duplicate_task``, ``taskrepo.save_task``,
|
||||
``taskrepo.delete_task``, ...) and ``self.scheduler.run_now(...)`` inline are
|
||||
now calls into
|
||||
``application/scheduling/task_application_service.py::TaskApplicationService``
|
||||
(R07-T04) — the drag-drop business rules (dropping on Running/Done/Scheduled)
|
||||
in particular used to be ~30 lines of if/elif inside a Qt slot; now it's
|
||||
``TaskApplicationService.move_to_status`` plus a few branches on its result.
|
||||
|
||||
Task EDITING (opening ``TaskEditorDialog``) is deliberately NOT owned here —
|
||||
``CalendarView`` needs the exact same "open the editor for this task id"
|
||||
behaviour for its own click handler, so it stays a shell-level concern
|
||||
(``schedule_task_tab.py``) both widgets request via a signal, instead of
|
||||
being duplicated in two places.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMenu, QMessageBox, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.scheduling.task_application_service import (
|
||||
TaskApplicationService,
|
||||
)
|
||||
from cowork_local.core.tasks import STATUSES, chain_error, new_task
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import (
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
# elevated priorities get a visible marker; low/medium stay unmarked as before.
|
||||
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
||||
|
||||
|
||||
class _KanbanColumn(QListWidget):
|
||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||
'move this task to my status'."""
|
||||
|
||||
task_dropped = Signal(str, str) # task_id, new_status
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__()
|
||||
self.status = status
|
||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||
self.setDefaultDropAction(Qt.MoveAction)
|
||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.setWordWrap(True)
|
||||
# Cards wrap, so there is never anything to reach by scrolling
|
||||
# sideways — but QListWidget's own column hint runs 1-6px past the
|
||||
# viewport; the board divides whatever width it has by seven instead
|
||||
# (see KanbanBoardWidget._fit_lanes()).
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
source = event.source()
|
||||
if isinstance(source, _KanbanColumn) and source is not self:
|
||||
item = source.currentItem()
|
||||
tid = item.data(Qt.UserRole) if item else None
|
||||
if tid:
|
||||
event.acceptProposedAction()
|
||||
self.task_dropped.emit(tid, self.status)
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
|
||||
class KanbanBoardWidget(QWidget):
|
||||
"""The 7-lane board: Backlog / Scheduled / Running / Waiting Input /
|
||||
Done / Failed / Paused. Cards drag between columns (dropping = changing
|
||||
status via ``TaskApplicationService.move_to_status``), double-click and
|
||||
the right-click menu request an edit via :attr:`edit_requested`.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext`` — passed through to ``TaskEditorDialog`` callers
|
||||
need it for, kept here only so callers don't have to fetch it
|
||||
separately.
|
||||
tasks_dir: ``None`` -> the app's default task-storage directory;
|
||||
tests pass a ``tmp_path``.
|
||||
scheduler: ``TaskScheduler`` (may be ``None`` — matches the original
|
||||
widget's "no scheduler in tests" tolerance) used as the
|
||||
``run_now`` dispatch source for the service.
|
||||
service: inject a ready-made ``TaskApplicationService`` (tests); when
|
||||
``None``, one is built from ``tasks_dir``/``scheduler``.
|
||||
"""
|
||||
|
||||
status_message = Signal(str)
|
||||
counts_changed = Signal(dict) # status -> count, for the shell's summary label
|
||||
edit_requested = Signal(str) # task_id — shell opens TaskEditorDialog
|
||||
|
||||
_LANE_FLOOR_CH = 8 # roughly eight characters of a task title, plus padding
|
||||
|
||||
def __init__(self, ctx, tasks_dir=None, scheduler=None,
|
||||
service: Optional[TaskApplicationService] = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._tasks_dir = tasks_dir
|
||||
self._repo = TaskRepository(tasks_dir)
|
||||
self._service = service or TaskApplicationService(
|
||||
self._repo, run_now=scheduler.run_now if scheduler is not None else None)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
board = QWidget()
|
||||
scroll.setWidget(board)
|
||||
cols = QHBoxLayout(board)
|
||||
cols.setSpacing(2)
|
||||
self.columns: Dict[str, _KanbanColumn] = {}
|
||||
self.column_headers: Dict[str, QLabel] = {}
|
||||
for status in STATUSES:
|
||||
box = QVBoxLayout()
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
box.setSpacing(2)
|
||||
head = QLabel()
|
||||
head.setStyleSheet("font-weight:600;")
|
||||
col = _KanbanColumn(status)
|
||||
col.setObjectName("kanbanLane")
|
||||
col.task_dropped.connect(self._on_task_dropped)
|
||||
col.itemDoubleClicked.connect(self._on_double_click)
|
||||
col.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
col.customContextMenuRequested.connect(
|
||||
lambda pos, c=col: self._context_menu(c, pos))
|
||||
box.addWidget(head)
|
||||
box.addWidget(col, 1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(box)
|
||||
cols.addWidget(holder)
|
||||
self.columns[status] = col
|
||||
self.column_headers[status] = head
|
||||
root.addWidget(scroll, 1)
|
||||
self._board_scroll = scroll
|
||||
scroll.viewport().installEventFilter(self)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
|
||||
# ---- lane widths ------------------------------------------------------
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
|
||||
self._fit_lanes()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _fit_lanes(self) -> None:
|
||||
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
|
||||
for col in self.columns.values():
|
||||
if col.minimumWidth() != floor:
|
||||
col.setMinimumWidth(floor)
|
||||
|
||||
# ---- rendering ----------------------------------------------------------
|
||||
def _card_text(self, t: dict) -> str:
|
||||
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
|
||||
ai = "[AI] " if t.get("is_ai_generated") else ""
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else None
|
||||
when_line = when or tr("schedtask.no_schedule")
|
||||
chain = ""
|
||||
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
|
||||
chain = " (linked)"
|
||||
last = t.get("logs", {}).get("last_status")
|
||||
last_line = {"success": tr("schedtask.last_success"),
|
||||
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
|
||||
return (f"{ai}{t.get('title', '')}{chain}\n"
|
||||
f"{when_line} {prio}\n{last_line}")
|
||||
|
||||
def refresh(self) -> List[dict]:
|
||||
"""Re-render every lane from disk. Returns the full task list so the
|
||||
shell can hand the same read to ``CalendarView.set_tasks`` without a
|
||||
second ``list_tasks`` call."""
|
||||
all_tasks = self._repo.list()
|
||||
counts = {s: 0 for s in STATUSES}
|
||||
for col in self.columns.values():
|
||||
col.clear()
|
||||
for t in all_tasks:
|
||||
status = t.get("status", "backlog")
|
||||
if status not in self.columns:
|
||||
continue
|
||||
counts[status] += 1
|
||||
item = QListWidgetItem(self._card_text(t))
|
||||
item.setData(Qt.UserRole, t["task_id"])
|
||||
self.columns[status].addItem(item)
|
||||
pal = current_palette()
|
||||
for status, col in self.columns.items():
|
||||
self.column_headers[status].setText(
|
||||
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
||||
# Dropping a card into Running STARTS the task for real, so that
|
||||
# lane is outlined while it holds anything.
|
||||
if status == "running" and counts[status]:
|
||||
col.setStyleSheet(
|
||||
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
||||
self.column_headers[status].setStyleSheet(
|
||||
f"font-weight:600; color: {pal.warning};")
|
||||
else:
|
||||
col.setStyleSheet("")
|
||||
self.column_headers[status].setStyleSheet("font-weight:600;")
|
||||
if col.count() == 0:
|
||||
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
col.addItem(empty)
|
||||
self.counts_changed.emit(counts)
|
||||
return all_tasks
|
||||
|
||||
# ---- actions --------------------------------------------------------
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self.edit_requested.emit(tid)
|
||||
|
||||
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
||||
"""Dropping a card ACTS on the task via ``TaskApplicationService.
|
||||
move_to_status`` — see that method's docstring for the exact rules."""
|
||||
result = self._service.move_to_status(task_id, new_status)
|
||||
if result is None:
|
||||
self.refresh()
|
||||
return
|
||||
if result.blocked:
|
||||
self.refresh() # can't drag a running task
|
||||
return
|
||||
if result.ran_now:
|
||||
self._emit_run_now_message(result.run_now_result,
|
||||
(result.task or {}).get("title", ""))
|
||||
self.refresh()
|
||||
return
|
||||
self.refresh()
|
||||
if result.needs_schedule:
|
||||
# No time set yet — a silently-disabled "Scheduled" card would
|
||||
# never run and look broken. Open the editor right away.
|
||||
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
||||
self.edit_requested.emit(task_id)
|
||||
|
||||
@staticmethod
|
||||
def _is_multi_selection(item, selected) -> bool:
|
||||
"""True when the right-clicked card is part of an existing multi-item
|
||||
selection — pure boolean, kept separate from _context_menu so it's
|
||||
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
||||
return len(selected) > 1 and item in selected
|
||||
|
||||
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
||||
item = col.itemAt(pos)
|
||||
if item is None or not item.data(Qt.UserRole):
|
||||
return
|
||||
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
||||
if self._is_multi_selection(item, selected):
|
||||
self._bulk_delete_menu(col, pos, selected)
|
||||
return
|
||||
tid = item.data(Qt.UserRole)
|
||||
task = self._repo.get(tid)
|
||||
if not task:
|
||||
return
|
||||
menu = QMenu(col)
|
||||
run_act = menu.addAction(tr("schedtask.menu_run"))
|
||||
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
||||
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
||||
paused = task.get("status") == "paused"
|
||||
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
||||
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
||||
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
||||
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == run_act:
|
||||
self._emit_run_now_message(self._service.run_now(tid), task.get("title", ""))
|
||||
self.refresh()
|
||||
elif chosen == edit_act:
|
||||
self.edit_requested.emit(tid)
|
||||
elif chosen == dup_act:
|
||||
self._service.duplicate(tid)
|
||||
self.refresh()
|
||||
elif chosen == pause_act:
|
||||
self._service.toggle_pause(tid)
|
||||
self.refresh()
|
||||
elif chosen == logs_act:
|
||||
self._view_logs(task)
|
||||
elif chosen == hist_act:
|
||||
RunHistoryDialog(task, self).exec()
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
self._service.delete(tid)
|
||||
self.refresh()
|
||||
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
menu = QMenu(col)
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._confirm_and_delete_selected(selected)
|
||||
|
||||
def _confirm_and_delete_selected(self, selected) -> bool:
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
return False
|
||||
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
|
||||
self._service.bulk_delete(ids)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def _emit_run_now_message(self, result, title: str = "") -> None:
|
||||
if result is None:
|
||||
return
|
||||
if result.ok:
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=title))
|
||||
elif result.reason == "manual_task":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
elif result.reason == "no_scheduler":
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
from cowork_local.core.tasks import ARTIFACTS_DIR
|
||||
|
||||
run_id = task.get("logs", {}).get("last_run_id")
|
||||
if not run_id:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
return
|
||||
folder = ARTIFACTS_DIR / task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
else:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
|
||||
def _create_next_from_output(self, task: dict) -> None:
|
||||
"""Scaffold a follow-up task pre-wired to consume this task's output.
|
||||
Chain-cycle validation (``chain_error``) is core/tasks.py domain
|
||||
logic already, not duplicated here — only the save + edit-request
|
||||
wiring is this widget's job."""
|
||||
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
||||
nxt["task_type"] = "cowork"
|
||||
nxt["input"]["mode"] = "previous_task_output"
|
||||
nxt["input"]["previous_task_id"] = task["task_id"]
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
self._repo.save(nxt)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
task["dependency"]["pass_output_to_next"] = True
|
||||
if task["dependency"].get("run_next_mode", "none") == "none":
|
||||
task["dependency"]["run_next_mode"] = "run_after_success"
|
||||
self._repo.save(task)
|
||||
self.refresh()
|
||||
self.edit_requested.emit(nxt["task_id"])
|
||||
|
||||
|
||||
__all__ = ["KanbanBoardWidget"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""RunHistoryDialog — one task's run history as a table (R08-T11, split out
|
||||
of ``kanban_board_widget.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/schedule_task_tab.py::_RunHistoryDialog``, lines 496-548)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QDialog, QDialogButtonBox, QLabel, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class RunHistoryDialog(QDialog):
|
||||
"""Run history of one task as a table (newest first): time, status, error;
|
||||
double-click a row to open that run's artifact folder."""
|
||||
|
||||
def __init__(self, task: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._task = task
|
||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||
self.resize(620, 380)
|
||||
root = QVBoxLayout(self)
|
||||
hint = QLabel(tr("schedtask.hist_hint"))
|
||||
hint.setObjectName("hint")
|
||||
root.addWidget(hint)
|
||||
|
||||
runs = list(reversed(task.get("runs", []) or []))
|
||||
self.table = QTableWidget(len(runs), 4)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||
])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
for row, run in enumerate(runs):
|
||||
cells = (
|
||||
run.get("finished_at", ""),
|
||||
str(run.get("status", "")),
|
||||
run.get("run_id", ""),
|
||||
(run.get("error") or "")[:200],
|
||||
)
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
from cowork_local.core.tasks import ARTIFACTS_DIR
|
||||
|
||||
first = self.table.item(item.row(), 0)
|
||||
run_id = first.data(Qt.UserRole) if first else ""
|
||||
if not run_id:
|
||||
return
|
||||
folder = ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
|
||||
|
||||
__all__ = ["RunHistoryDialog"]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""ScheduleTaskTab shell (R08-T11) — assembles
|
||||
``kanban_board_widget.py::KanbanBoardWidget`` and
|
||||
``calendar_view_widget.py::CalendarView`` behind the header/view-switch that
|
||||
used to be inline in ``ui/schedule_task_tab.py`` (lines 81-245 of the
|
||||
original 795-line file: header, view-tab wiring, lane-fit event filter moved
|
||||
into the Kanban widget itself, the belt-and-braces 10s refresh timer).
|
||||
|
||||
Task EDITING (opening ``TaskEditorDialog``) lives HERE, not in either child
|
||||
widget, because both need the exact same "open the editor for this task id"
|
||||
behaviour — Kanban's double-click/edit-menu and Calendar's task click both
|
||||
request it via a signal instead of each importing ``TaskEditorDialog``
|
||||
themselves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.tasks import STATUSES, list_tasks, load_task, new_task, save_task
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.scheduling.calendar_view_widget import CalendarView
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
_VIEWS = ("kanban", "calendar")
|
||||
|
||||
|
||||
class ScheduleTaskTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None, tasks_dir: Optional[Path] = None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
||||
# None -> the app's default TASKS_DIR (core/tasks.py). Overridable
|
||||
# (new in R08-T11; the original monolithic tab hardcoded None with no
|
||||
# way to point it at a tmp_path) so this shell is actually testable
|
||||
# without touching the user's real config folder — same shape
|
||||
# TaskScheduler.__init__ already accepts.
|
||||
self._tasks_dir: Optional[Path] = tasks_dir
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# ---- header ----------------------------------------------------
|
||||
header = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.counts_lbl = QLabel("")
|
||||
self.counts_lbl.setObjectName("hint")
|
||||
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||
self.counts_lbl.setMinimumWidth(0)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.clicked.connect(self._add_task)
|
||||
self.ai_btn = QPushButton()
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.clicked.connect(self._ai_create)
|
||||
# Two views of the same tasks, so they read as a pair of tabs rather
|
||||
# than a drop-list you have to open to discover the Calendar exists.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
for _v in _VIEWS:
|
||||
self.view_tabs.addTab("")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
||||
header.addWidget(self._title)
|
||||
header.addWidget(self.counts_lbl, 1)
|
||||
header.addWidget(self.view_tabs)
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.ai_btn)
|
||||
root.addLayout(header)
|
||||
|
||||
# ---- board / calendar (two views of the SAME tasks) -----------------
|
||||
self._view_stack = QStackedWidget()
|
||||
self.kanban = KanbanBoardWidget(ctx, tasks_dir=self._tasks_dir, scheduler=scheduler)
|
||||
self.kanban.status_message.connect(self.status_message.emit)
|
||||
self.kanban.counts_changed.connect(self._on_counts_changed)
|
||||
self.kanban.edit_requested.connect(self._edit_task)
|
||||
self._view_stack.addWidget(self.kanban)
|
||||
self.calendar = CalendarView()
|
||||
self.calendar.edit_task.connect(self._edit_task)
|
||||
self.calendar.add_task_on_date.connect(self._add_task_on_date)
|
||||
self._view_stack.addWidget(self.calendar)
|
||||
root.addWidget(self._view_stack, 1)
|
||||
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.tasks_changed.connect(self.refresh)
|
||||
self.scheduler.task_started.connect(lambda _tid: self.refresh())
|
||||
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
|
||||
|
||||
# Belt-and-braces: also re-read the board every 10s so a card's lane
|
||||
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
|
||||
# change slipped past the signals (e.g. task files edited externally).
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setInterval(10_000)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._refresh_timer.start()
|
||||
|
||||
self.refresh()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("schedtask.title"))
|
||||
self.add_btn.setText(tr("schedtask.add_btn"))
|
||||
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
|
||||
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
||||
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
||||
for i, v in enumerate(_VIEWS):
|
||||
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
||||
self.kanban.retranslate()
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _on_counts_changed(self, counts: dict) -> None:
|
||||
summary = " ".join(
|
||||
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
|
||||
self.counts_lbl.setText(summary)
|
||||
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
|
||||
|
||||
def refresh(self) -> None:
|
||||
all_tasks = self.kanban.refresh()
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- task creation / editing (shared by Kanban + Calendar) -----------
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _add_task(self) -> None:
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
def _edit_task(self, task_id: str) -> None:
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
# ---- AI create ----------------------------------------------------------
|
||||
def _ai_create(self) -> None:
|
||||
from cowork_local.presentation.scheduling.ai_task_creator_dialog import (
|
||||
AiTaskCreatorDialog,
|
||||
)
|
||||
|
||||
dlg = AiTaskCreatorDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
|
||||
|
||||
__all__ = ["ScheduleTaskTab"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Small pieces shared across more than one presentation screen (EPIC R08).
|
||||
|
||||
Kept intentionally minimal — this is NOT a dumping ground for every reusable
|
||||
widget (``ui/icons.py``, ``ui/widgets.py``, ``ui/routing_toggle.py`` stay
|
||||
where they are; migrating those is a separate concern from R08-T12/T14).
|
||||
Only ``HAS_WEB_ENGINE`` lives here so far — it was one module-level flag
|
||||
duplicated between two God files being split by two different R08 tasks
|
||||
(``ui/folder_tab.py`` and ``ui/structure_graph_view.py``), and a shared
|
||||
constant beats one screen importing another screen's module.
|
||||
"""
|
||||
|
||||
from .web_engine_support import HAS_WEB_ENGINE
|
||||
|
||||
__all__ = ["HAS_WEB_ENGINE"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""HAS_WEB_ENGINE — whether ``QWebEngineView`` is safe to construct here
|
||||
(R08-T12/T14, extracted from ``ui/structure_graph_view.py``, lines 25-48 of
|
||||
its original 1035-line version — the ONLY place this detection logic lived;
|
||||
``ui/folder_tab.py`` used to import it FROM that module via a try/except).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _frozen_onefile() -> bool:
|
||||
"""True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
|
||||
temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
|
||||
can't run — creating a QWebEngineView hard-crashes the app (reported as
|
||||
"click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
|
||||
``_internal`` folder right next to the exe, where WebEngine works fine, so
|
||||
it keeps the full embedded D3/HTML view."""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return False
|
||||
meipass = getattr(sys, "_MEIPASS", "")
|
||||
if not meipass:
|
||||
return False
|
||||
try:
|
||||
return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
|
||||
except OSError: # can't tell → play safe: use the native/fallback view
|
||||
return True
|
||||
|
||||
|
||||
try: # WebEngine + WebChannel are optional PySide6 add-ons
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
|
||||
from PySide6.QtWebChannel import QWebChannel # noqa: F401
|
||||
HAS_WEB_ENGINE = not _frozen_onefile()
|
||||
except Exception: # pragma: no cover
|
||||
HAS_WEB_ENGINE = False
|
||||
|
||||
|
||||
__all__ = ["HAS_WEB_ENGINE"]
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
from .config import AppConfig
|
||||
from .infrastructure.mcp import McpToolSourceManager
|
||||
|
||||
|
||||
def resolve_agent_default(
|
||||
@@ -38,18 +39,18 @@ class AppContext:
|
||||
def __init__(self, config: AppConfig):
|
||||
self.config = config
|
||||
self.started_at = time.time() # for Monitoring's Sandbox Details "Created"/"Uptime"
|
||||
self._mcp_connections: dict = {} # server name -> McpServerConnection
|
||||
# Admin-configured MCP servers + the built-in MS365 server (R05-T05):
|
||||
# connection caching/lifecycle (check-then-create, restart, shutdown)
|
||||
# now lives in McpToolSourceManager, extracted so it is testable
|
||||
# without an AppContext/Qt. See its docstring for why the check-then-
|
||||
# create race matters — several turns (multiple Cowork tabs, parallel
|
||||
# Co4E flows, scheduled tasks) can call build_mcp_tools() at once.
|
||||
self._mcp_manager = McpToolSourceManager()
|
||||
self._ext_connections: dict = {} # connector id -> McpServerConnection (mcp_stdio mode only)
|
||||
# Guards the two connection caches above. build_mcp_tools() runs on EVERY
|
||||
# chat turn's own AgentWorker thread, so several turns (multiple Cowork
|
||||
# tabs, parallel Co4E flows, scheduled tasks) can enter it at once. The
|
||||
# cache is populated check-then-create ("conn is None → spawn → store");
|
||||
# without this lock two concurrent turns both see None and each spawns a
|
||||
# subprocess for the SAME server — one leaks as an orphan and the wrong
|
||||
# object may be handed out. The lock makes connection setup atomic; the
|
||||
# provider/HTTP path itself is already thread-safe (a fresh provider per
|
||||
# call, module-level `requests`, MCP calls multiplexed on the server's
|
||||
# own event loop), so concurrent model calls never needed serializing.
|
||||
# Guards ``_ext_connections`` only now — unified Connectors (CAD/CAE/
|
||||
# MS365/Other) aren't covered by McpToolSourceManager (R05-T05 scoped
|
||||
# to MCP servers), so this cache still needs its own check-then-create
|
||||
# lock, the same race McpToolSourceManager guards against internally.
|
||||
self._conn_lock = threading.Lock()
|
||||
self._routing_service = None # lazy RoutingService (Auto Model Routing)
|
||||
# Lazy RoutingApplicationService (R03-T03) — the Qt-free decision layer
|
||||
@@ -237,48 +238,41 @@ class AppContext:
|
||||
if not self.config.connect_external:
|
||||
return [], None
|
||||
from .core.ext_connectors import build_ext_connector_tools
|
||||
from .core.mcp_client import McpServerConnection
|
||||
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
|
||||
from .core.tools import combine_tool_sources
|
||||
|
||||
# Serialize the check-then-create against the connection caches so
|
||||
# concurrent turns share one subprocess per server instead of racing to
|
||||
# spawn duplicates (see _conn_lock in __init__). The lock is held while
|
||||
# connections are established (a one-time cost per server per app run);
|
||||
# once warm, every turn just finds the cached connection and returns.
|
||||
with self._conn_lock:
|
||||
active = []
|
||||
for entry in self.config.mcp_servers:
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
name = entry.get("name", "")
|
||||
command = entry.get("command", "")
|
||||
if not name or not command:
|
||||
continue
|
||||
conn = self._mcp_connections.get(name)
|
||||
if conn is None:
|
||||
conn = McpServerConnection(name, command, entry.get("args") or [],
|
||||
entry.get("env") or None)
|
||||
try:
|
||||
conn.start()
|
||||
except Exception: # noqa: BLE001 - one broken server must not block the turn
|
||||
continue
|
||||
self._mcp_connections[name] = conn
|
||||
# R05-T05: connection caching/check-then-create for admin-configured
|
||||
# servers + the MS365 builtin now lives in McpToolSourceManager (its
|
||||
# own lock guards the race — see its docstring).
|
||||
active = []
|
||||
for entry in self.config.mcp_servers:
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
name = entry.get("name", "")
|
||||
command = entry.get("command", "")
|
||||
if not name or not command:
|
||||
continue
|
||||
conn = self._mcp_manager.ensure(name, command, entry.get("args") or [],
|
||||
entry.get("env") or None)
|
||||
if conn is not None:
|
||||
active.append(conn)
|
||||
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
|
||||
if builtin is not None:
|
||||
active.append(builtin)
|
||||
mcp_tools, mcp_executor = _merge_mcp_tools(active)
|
||||
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
|
||||
if builtin is not None:
|
||||
active.append(builtin)
|
||||
mcp_tools, mcp_executor = _merge_mcp_tools(active)
|
||||
|
||||
# ``_ext_connections`` isn't covered by McpToolSourceManager (T05
|
||||
# scoped to MCP servers) — still serialized under ``_conn_lock``.
|
||||
with self._conn_lock:
|
||||
ext = self.config.ext_connectors
|
||||
all_connectors = [*ext.get("cad", []), *ext.get("cae", []),
|
||||
*ext.get("ms365", []), *ext.get("other", [])]
|
||||
ext_tools, ext_executor = build_ext_connector_tools(all_connectors, self._ext_connections)
|
||||
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
|
||||
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
|
||||
from .core.ms365_local import build_ms365_local_tools
|
||||
local_tools, local_executor = build_ms365_local_tools(self.config)
|
||||
|
||||
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
|
||||
(local_tools, local_executor))
|
||||
@@ -308,36 +302,20 @@ class AppContext:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .core.mcp_client import McpServerConnection
|
||||
|
||||
name = self._MS365_BUILTIN
|
||||
if name in skip:
|
||||
return None
|
||||
if not self._ms365_available():
|
||||
stale = self._mcp_connections.pop(name, None)
|
||||
if stale is not None:
|
||||
try:
|
||||
stale.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._mcp_manager.stop(name)
|
||||
return None
|
||||
conn = self._mcp_connections.get(name)
|
||||
if conn is None:
|
||||
# The subprocess must import cowork_local even in a from-source run
|
||||
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
|
||||
env = dict(os.environ)
|
||||
src_root = str(Path(__file__).resolve().parent.parent)
|
||||
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
|
||||
if env.get("PYTHONPATH") else src_root)
|
||||
conn = McpServerConnection(
|
||||
name, sys.executable,
|
||||
["-m", "cowork_local.mcp_servers.ms365_server"], env)
|
||||
try:
|
||||
conn.start()
|
||||
except Exception: # noqa: BLE001 - MS365 down must not block the turn
|
||||
return None
|
||||
self._mcp_connections[name] = conn
|
||||
return conn
|
||||
# The subprocess must import cowork_local even in a from-source run
|
||||
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
|
||||
env = dict(os.environ)
|
||||
src_root = str(Path(__file__).resolve().parent.parent)
|
||||
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
|
||||
if env.get("PYTHONPATH") else src_root)
|
||||
return self._mcp_manager.ensure(
|
||||
name, sys.executable, ["-m", "cowork_local.mcp_servers.ms365_server"], env)
|
||||
|
||||
def stop_mcp_connections(self) -> None:
|
||||
"""Terminate every connected MCP server's subprocess (incl. External
|
||||
@@ -345,11 +323,6 @@ class AppContext:
|
||||
them linger as orphan processes."""
|
||||
from .core.ext_connectors import stop_ext_connections
|
||||
|
||||
self._mcp_manager.stop_all()
|
||||
with self._conn_lock:
|
||||
for conn in self._mcp_connections.values():
|
||||
try:
|
||||
conn.stop()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._mcp_connections.clear()
|
||||
stop_ext_connections(self._ext_connections)
|
||||
|
||||
@@ -9,8 +9,13 @@ laptop, in CI and on a machine with no API keys configured.
|
||||
* :class:`~tests.fakes.fake_tool_executor.FakeToolExecutor` - a scripted stand-in
|
||||
for the ``extra_executor`` callable that ``core.chat_agent.run_cowork`` routes
|
||||
MCP/connector tool calls to.
|
||||
* :class:`~tests.fakes.fake_clock.FakeClock` - a manually-fired stand-in for
|
||||
``platform/qt/qt_scheduler_clock.py::QtSchedulerClock`` (R07-T03), so
|
||||
``TaskScheduler`` dispatch logic can be tested tick-by-tick with no Qt
|
||||
event loop running.
|
||||
"""
|
||||
from .fake_clock import FakeClock
|
||||
from .fake_provider import FakeProvider, ScriptedTurn
|
||||
from .fake_tool_executor import FakeToolExecutor, ToolInvocation
|
||||
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "FakeToolExecutor", "ToolInvocation"]
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "FakeToolExecutor", "ToolInvocation", "FakeClock"]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""FakeClock - offline stand-in for ``platform/qt/qt_scheduler_clock.py::
|
||||
QtSchedulerClock`` (R07-T03).
|
||||
|
||||
``TaskScheduler`` (``core/task_scheduler.py``) needs a clock that can
|
||||
``start(interval_ms, callback)`` / ``stop()`` / ``pump()``. In production
|
||||
that's a real ``QTimer``, which means testing dispatch logic (what runs, in
|
||||
what order, what gets re-armed) would otherwise require a live Qt event loop
|
||||
ticking every 30 seconds. This double satisfies the same duck-typed
|
||||
interface with manual control: ``fire()`` calls the scripted callback once,
|
||||
synchronously, on whichever thread the test is running on - no timers, no
|
||||
event loop, no waiting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Scriptable stand-in for :class:`QtSchedulerClock`.
|
||||
|
||||
Args:
|
||||
running: whether ``start()`` has been called and ``stop()`` hasn't
|
||||
since - a test can assert on this to check lifecycle wiring.
|
||||
pump_count: how many times ``pump()`` was called - lets a test on
|
||||
``TaskScheduler.stop()``'s drain loop assert the event loop was
|
||||
actually pumped while waiting for workers.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._callback: Optional[Callable[[], None]] = None
|
||||
self.interval_ms: Optional[int] = None
|
||||
self.running: bool = False
|
||||
self.pump_count: int = 0
|
||||
|
||||
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
|
||||
self.interval_ms = interval_ms
|
||||
self._callback = callback
|
||||
self.running = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.running = False
|
||||
|
||||
def pump(self) -> None:
|
||||
self.pump_count += 1
|
||||
|
||||
def fire(self) -> None:
|
||||
"""Test helper: manually trigger one tick, as if the interval had
|
||||
elapsed. A no-op when the clock isn't running (matches a real
|
||||
``QTimer`` never firing after ``stop()``)."""
|
||||
if self.running and self._callback is not None:
|
||||
self._callback()
|
||||
|
||||
|
||||
__all__ = ["FakeClock"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""EPIC R08-T13: TokenUsageCardWidget / UsageChartWidget / HabitsWidget /
|
||||
DashboardTab shell, real Qt offscreen.
|
||||
|
||||
``DashboardQueryService``'s own logic is unit tested (R08-T13, no Qt) in
|
||||
``tests/unit/test_dashboard_query_service.py``; this file proves the three
|
||||
widgets and the shell are actually wired to it and to each other (the period
|
||||
selector living on ``UsageChartWidget`` driving all three).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core import usage_tracker as ut # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "usage"
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _write_event(usage_dir, day: date, **overrides):
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat",
|
||||
"provider": "anthropic", "model": "claude-sonnet-4-6",
|
||||
"in": 100, "out": 50, "cache": 0, "estimated": False,
|
||||
"account": "", "machine": "",
|
||||
}
|
||||
event.update(overrides)
|
||||
path = usage_dir / f"{day.isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def test_dashboard_tab_builds_and_populates_cards(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
_write_event(usage_dir, date.today())
|
||||
tab = DashboardTab(ctx) # refresh() runs once at construction
|
||||
|
||||
assert "100" in tab.token_cards.card_in.value_lbl.text() \
|
||||
or tab.token_cards.card_in.value_lbl.text() # non-crashing, has SOME text
|
||||
assert tab.token_cards.card_total.value_lbl.text() != ""
|
||||
|
||||
|
||||
def test_period_navigation_on_chart_refreshes_the_whole_shell(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
_write_event(usage_dir, date.today())
|
||||
tab = DashboardTab(ctx)
|
||||
calls = []
|
||||
tab.refresh = lambda *a, orig=tab.refresh: (calls.append(1), orig(*a))[-1]
|
||||
|
||||
tab.chart._chart_prev()
|
||||
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_granularity_change_resets_offset_to_current(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
|
||||
tab = DashboardTab(ctx)
|
||||
tab.chart._chart_offset = -3
|
||||
|
||||
idx = tab.chart.gran_combo.findData("month")
|
||||
tab.chart.gran_combo.setCurrentIndex(idx)
|
||||
|
||||
assert tab.chart.chart_offset == 0
|
||||
|
||||
|
||||
def test_currency_change_persists_and_triggers_refresh(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
tab = DashboardTab(ctx)
|
||||
idx = tab.chart.currency_combo.findData("EUR") \
|
||||
if tab.chart.currency_combo.findData("EUR") >= 0 else 0
|
||||
seen = []
|
||||
tab.chart.currency_changed.connect(lambda: seen.append(1))
|
||||
|
||||
tab.chart.currency_combo.setCurrentIndex(idx)
|
||||
|
||||
if tab.chart.currency_combo.currentData() != "USD":
|
||||
assert seen == [1]
|
||||
assert ctx.config.data["usage"]["currency"] == tab.chart.currency_combo.currentData()
|
||||
|
||||
|
||||
def test_habits_widget_ai_analyze_noop_without_data(usage_dir, ctx):
|
||||
"""No events in range -> emits a status message instead of starting a
|
||||
background worker (matches the original _ai_analyze guard)."""
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
|
||||
query = DashboardQueryService(ctx)
|
||||
widget = HabitsWidget(ctx, query)
|
||||
widget.refresh(date(2020, 1, 1), date(2020, 1, 1))
|
||||
messages = []
|
||||
widget.status_message.connect(messages.append)
|
||||
|
||||
widget._ai_analyze()
|
||||
|
||||
assert messages # "no data" status, no worker started
|
||||
assert widget._ai_worker is None
|
||||
|
||||
|
||||
def test_budget_apply_updates_budget_card(usage_dir, ctx):
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
|
||||
query = DashboardQueryService(ctx)
|
||||
widget = TokenUsageCardWidget(ctx, query)
|
||||
widget.budget_card.budget_spin.setValue(50.0)
|
||||
|
||||
widget._apply_budget()
|
||||
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == pytest.approx(50.0)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""EPIC R08-T12: WorkspaceFileTree / DocumentPreviewManager / FolderTab,
|
||||
real Qt offscreen.
|
||||
|
||||
Scope note: the AI-Edit panel's send -> plan -> edit -> apply pipeline
|
||||
(``ai_edit_pipeline.py``) runs on a real ``core.worker.AgentWorker`` QThread
|
||||
and had ZERO existing tests before this task (confirmed by grep — nothing
|
||||
under tests/ exercised ``ui/folder_tab.py``'s AI methods except the routing
|
||||
surface test, fixed alongside this task in
|
||||
``tests/integration/test_routing_surfaces.py``). Driving that full async
|
||||
pipeline end to end is out of scope here; what's covered is everything that
|
||||
doesn't need a live QThread: navigation, preview rendering, and — the
|
||||
concrete R08-T12 deliverable — that saves/creates actually go through
|
||||
``FileWorkspaceService`` (containment enforced, not just "writes a file").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "workspace"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ---- WorkspaceFileTree ----------------------------------------------------- #
|
||||
def test_workspace_file_tree_set_root_updates_label_and_model(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
other = root.parent / "other-root"
|
||||
other.mkdir()
|
||||
|
||||
tree.set_root(str(other))
|
||||
|
||||
assert tree.root == str(other)
|
||||
assert tree.path_lbl.text() == str(other)
|
||||
|
||||
|
||||
def test_workspace_file_tree_ignores_an_invalid_root(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
tree.set_root(str(root / "does-not-exist"))
|
||||
|
||||
assert tree.root == str(root) # unchanged
|
||||
|
||||
|
||||
def test_workspace_file_tree_click_emits_file_selected(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
f = root / "a.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
seen = []
|
||||
tree.file_selected.connect(seen.append)
|
||||
|
||||
index = tree.model.index(str(f))
|
||||
tree._on_tree_clicked(index)
|
||||
|
||||
assert seen and os.path.normpath(seen[0]) == os.path.normpath(str(f))
|
||||
|
||||
|
||||
# ---- DocumentPreviewManager ------------------------------------------------- #
|
||||
def test_open_file_renders_code_into_the_editor(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "script.py"
|
||||
f.write_text("print('hi')\n", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
preview.open_file(str(f))
|
||||
|
||||
assert preview.stack.currentWidget() is preview.editor
|
||||
assert preview.editor.toPlainText() == "print('hi')\n"
|
||||
assert preview.current_file == str(f)
|
||||
|
||||
|
||||
def test_open_file_on_a_binary_file_shows_the_placeholder(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "data.bin"
|
||||
f.write_bytes(b"\x00\x01\x02binary")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
preview.open_file(str(f))
|
||||
|
||||
assert preview.stack.currentWidget() is preview._placeholder
|
||||
|
||||
|
||||
def test_save_writes_through_file_workspace_service(qt_app, root):
|
||||
"""The concrete R08-T12/R06-T05 deliverable: FileWorkspaceService (built
|
||||
at R06, unused until this task) is now the actual write path."""
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "note.txt"
|
||||
f.write_text("old", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
preview.open_file(str(f))
|
||||
|
||||
preview.editor.setPlainText("new content")
|
||||
preview.save()
|
||||
|
||||
assert f.read_text(encoding="utf-8") == "new content"
|
||||
|
||||
|
||||
def test_create_new_file_writes_inside_root_via_file_workspace_service(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
dest = preview.create_new_file("sub/new.md", "# hello")
|
||||
|
||||
assert dest == str(root / "sub" / "new.md")
|
||||
assert (root / "sub" / "new.md").read_text(encoding="utf-8") == "# hello"
|
||||
assert preview.current_file == dest # opened it, kept the AI chat (reset_ai=False)
|
||||
|
||||
|
||||
def test_create_new_file_rejects_a_path_escaping_the_root(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
messages = []
|
||||
preview.status_message.connect(messages.append)
|
||||
|
||||
dest = preview.create_new_file("../escaped.txt", "nope")
|
||||
|
||||
assert dest is None
|
||||
assert not (root.parent / "escaped.txt").exists()
|
||||
assert messages # a save_error status was emitted
|
||||
|
||||
|
||||
def test_write_content_persists_and_refreshes_html_preview(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "page.html"
|
||||
f.write_text("<p>old</p>", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
preview.open_file(str(f)) # HTML opens in preview mode by default
|
||||
|
||||
preview.write_content("<p>new</p>")
|
||||
|
||||
assert f.read_text(encoding="utf-8") == "<p>new</p>"
|
||||
|
||||
|
||||
# ---- FolderTab shell -------------------------------------------------------- #
|
||||
def test_folder_tab_builds_and_wires_tree_to_preview(ctx, root):
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
tab = FolderTab(ctx)
|
||||
tab.set_root(str(root))
|
||||
f = root / "hello.py"
|
||||
f.write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
tab.tree.file_selected.emit(str(f)) # simulate a tree click
|
||||
|
||||
assert tab.preview.current_file == str(f)
|
||||
assert tab.preview.editor.toPlainText() == "x = 1\n"
|
||||
|
||||
|
||||
def test_folder_tab_ai_panel_toggle_updates_button_badge(ctx, root):
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
tab = FolderTab(ctx)
|
||||
assert tab.ai_panel.isHidden()
|
||||
|
||||
tab.ai_btn.setChecked(True)
|
||||
tab._toggle_ai_panel()
|
||||
|
||||
assert not tab.ai_panel.isHidden()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""EPIC R06-T04: the race in ``ui/workspace_tab.py::_load_current``.
|
||||
|
||||
``_load_current`` sets ``ctx.config._project_history_dir`` on the SHARED
|
||||
``AppConfig`` every time the user switches projects in the Workspace screen.
|
||||
A background turn (one that isn't the conversation currently displayed) used
|
||||
to resolve its save directory by calling ``ctx.config.history_dir()`` at
|
||||
``_persist_session`` time - i.e. whenever the turn actually finished, not
|
||||
when it started. If the user switched projects while it was still running,
|
||||
the turn's conversation got written into the NEW project's history folder
|
||||
instead of the one it actually belongs to.
|
||||
|
||||
The fix threads a ``home_history_dir`` captured at submit time (same "home_*"
|
||||
snapshot convention ``ui/chat_panel.py`` already uses for session id/title/
|
||||
messages) through to the save call. This test drives the real
|
||||
``ChatPanel._persist_session`` - the actual save path - offscreen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core.history import list_conversations # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_panel(qt_app, tmp_path: Path):
|
||||
from cowork_local.ui.chat_panel import ChatPanel
|
||||
|
||||
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
return ChatPanel(ctx, "cowork", "Test")
|
||||
|
||||
|
||||
def test_background_turn_saves_into_the_project_it_started_in(chat_panel, tmp_path):
|
||||
project_a_dir = tmp_path / "project-a-history"
|
||||
project_b_dir = tmp_path / "project-b-history"
|
||||
chat_panel.ctx.config._project_history_dir = project_a_dir
|
||||
|
||||
# What ChatPanel._start_turn captures into the per-turn ctx dict at
|
||||
# submit time (see the "home_history_dir" entry added there for R06-T04).
|
||||
turn_ctx = {
|
||||
"home_id": chat_panel.session_id,
|
||||
"home_messages": [{"role": "user", "content": "hi"}],
|
||||
"home_title": "Background turn",
|
||||
"home_history_dir": chat_panel.ctx.config.history_dir(),
|
||||
"record": {},
|
||||
}
|
||||
assert turn_ctx["home_history_dir"] == project_a_dir
|
||||
|
||||
# The user switches projects in the Workspace screen WHILE this turn is
|
||||
# still running - exactly what ui/workspace_tab.py::_load_current does.
|
||||
chat_panel.ctx.config._project_history_dir = project_b_dir
|
||||
|
||||
chat_panel._persist_session(turn_ctx)
|
||||
|
||||
assert len(list_conversations(project_a_dir)) == 1
|
||||
assert list_conversations(project_b_dir) == []
|
||||
|
||||
|
||||
def test_the_currently_viewed_conversation_still_follows_live_selection(chat_panel, tmp_path):
|
||||
"""_save_snapshot's OTHER caller (the initial "register it in History right
|
||||
away" call, and _autosave) has no captured history_dir and must keep
|
||||
resolving it live - that path is for the conversation ACTUALLY on screen,
|
||||
which should follow whatever project the user has selected right now."""
|
||||
project_dir = tmp_path / "currently-viewed"
|
||||
chat_panel.ctx.config._project_history_dir = project_dir
|
||||
|
||||
chat_panel._save_snapshot(chat_panel.session_id,
|
||||
[{"role": "user", "content": "hi"}], "Live view")
|
||||
|
||||
assert len(list_conversations(project_dir)) == 1
|
||||
@@ -0,0 +1,53 @@
|
||||
"""EPIC R07-T03: QtSchedulerClock against a REAL QTimer/event loop.
|
||||
|
||||
``tests/unit/test_task_scheduler_dispatch.py`` covers ``TaskScheduler``'s
|
||||
dispatch logic entirely through ``tests/fakes/fake_clock.py::FakeClock`` (no
|
||||
Qt at all — that's the whole point of the extraction). This file is the
|
||||
complement: it proves the adapter itself actually drives a real ``QTimer``
|
||||
and pumps a real event loop, offscreen, the way ``test_history_dir_race.py``
|
||||
proves ``ui/chat_panel.py``'s fix against real Qt rather than a double.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from PySide6.QtTest import QTest # noqa: E402
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from cowork_local.infrastructure.qt.qt_scheduler_clock import QtSchedulerClock # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_start_fires_callback_on_the_real_qt_event_loop(qapp):
|
||||
clock = QtSchedulerClock()
|
||||
ticks = []
|
||||
clock.start(interval_ms=10, callback=lambda: ticks.append(1))
|
||||
try:
|
||||
QTest.qWait(200) # let the real QTimer fire a few times
|
||||
finally:
|
||||
clock.stop()
|
||||
assert len(ticks) >= 1
|
||||
|
||||
|
||||
def test_stop_prevents_further_callbacks(qapp):
|
||||
clock = QtSchedulerClock()
|
||||
ticks = []
|
||||
clock.start(interval_ms=10, callback=lambda: ticks.append(1))
|
||||
QTest.qWait(50)
|
||||
clock.stop()
|
||||
count_after_stop = len(ticks)
|
||||
QTest.qWait(100)
|
||||
assert len(ticks) == count_after_stop # no more callbacks after stop()
|
||||
|
||||
|
||||
def test_pump_processes_pending_events_without_raising(qapp):
|
||||
clock = QtSchedulerClock()
|
||||
clock.pump() # must not raise even with nothing pending
|
||||
@@ -6,7 +6,7 @@ of that algorithm now call it, on real (offscreen) widgets:
|
||||
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
* ``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.apply_routing`` (AI-Edit)
|
||||
|
||||
It also pins the Manual-mode handshake, including the field contract the
|
||||
existing confirm dialog reads off the decision - the one place where the new
|
||||
@@ -189,15 +189,20 @@ def test_co4e_returns_an_empty_model_when_routing_is_off(ctx):
|
||||
# AI-Edit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ai_edit_routes_on_its_own_surface_key(ctx):
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
"""R08-T12: the routing call this test pins moved from
|
||||
``ui/folder_tab.py::FolderTab._ai_apply_routing`` to
|
||||
``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.
|
||||
apply_routing`` - same RoutingApplicationService call, same surface key,
|
||||
now independently testable without the whole FolderTab widget tree."""
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
tab.ai_panel.resolver.apply_routing("rename this variable")
|
||||
|
||||
assert router.surfaces == ["ai_edit"]
|
||||
assert (tab._ai_routed_provider, tab._ai_routed_model) == (
|
||||
assert (tab.ai_panel.resolver.routed_provider, tab.ai_panel.resolver.routed_model) == (
|
||||
"anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
@@ -206,7 +211,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
classification entirely - the constraint has to survive the move into the
|
||||
shared service or it is silently dropped."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
seen: List[Any] = []
|
||||
|
||||
@@ -219,7 +224,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
_Recorder(), mode_reader=lambda _s: "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
tab.ai_panel.resolver.apply_routing("rename this variable")
|
||||
|
||||
assert seen == [TaskType.CODING]
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""EPIC R08-T11: ScheduleTaskTab shell + KanbanBoardWidget, real Qt offscreen.
|
||||
|
||||
Drives the real widgets end to end (build -> refresh -> drag-drop rule via
|
||||
TaskApplicationService -> refresh) against a tmp_path task repository, the
|
||||
way ``test_history_dir_race.py`` proves R06-T04 against real Qt rather than
|
||||
a double. ``TaskApplicationService``'s own business rules are already unit
|
||||
tested (R07-T04); this file exists to prove the WIDGET is actually wired to
|
||||
that service, not to re-test the rules themselves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import ( # noqa: E402
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tasks_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "tasks"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
def test_schedule_task_tab_builds_and_refreshes_with_no_tasks(ctx, tasks_dir):
|
||||
from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir)
|
||||
tab.refresh() # must not raise against an empty repo
|
||||
|
||||
assert tab.kanban.columns.keys() # 7 lanes were built
|
||||
|
||||
|
||||
def test_kanban_renders_a_task_into_its_status_lane(ctx, tasks_dir):
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("My Task", task_type="cowork")
|
||||
task["status"] = "backlog"
|
||||
repo.save(task)
|
||||
|
||||
board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir)
|
||||
board.refresh()
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
backlog_ids = [board.columns["backlog"].item(i).data(Qt.UserRole)
|
||||
for i in range(board.columns["backlog"].count())]
|
||||
assert task["task_id"] in backlog_ids
|
||||
|
||||
|
||||
def test_dropping_a_card_on_done_disables_its_schedule_through_the_real_widget(ctx, tasks_dir):
|
||||
"""Same rule TaskApplicationService.move_to_status covers at the unit
|
||||
level (R07-T04) — this proves the Kanban widget's drop handler actually
|
||||
calls it, end to end, with a real TaskRepository on disk."""
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("Recurring", task_type="cowork")
|
||||
task["schedule"]["enabled"] = True
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
task["schedule"]["repeat_type"] = "daily"
|
||||
task["status"] = "scheduled"
|
||||
repo.save(task)
|
||||
|
||||
board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir)
|
||||
board.refresh()
|
||||
|
||||
board._on_task_dropped(task["task_id"], "done")
|
||||
|
||||
on_disk = repo.get(task["task_id"])
|
||||
assert on_disk["status"] == "done"
|
||||
assert on_disk["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_kanban_edit_requested_is_wired_to_the_shells_edit_task(ctx, tasks_dir, monkeypatch):
|
||||
"""Proves ScheduleTaskTab actually connects
|
||||
``kanban.edit_requested -> self._edit_task`` (not just that the Kanban
|
||||
widget emits the signal in isolation) by monkeypatching the dialog class
|
||||
``_edit_task`` opens and checking it was constructed for the right task."""
|
||||
import cowork_local.ui.task_editor_dialog as task_editor_dialog_module
|
||||
from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("Editable")
|
||||
repo.save(task)
|
||||
|
||||
seen_task_ids = []
|
||||
|
||||
class _FakeDialog:
|
||||
def __init__(self, task, all_tasks, parent, ctx):
|
||||
seen_task_ids.append(task["task_id"] if task else None)
|
||||
self.edited_task = None
|
||||
|
||||
def exec(self):
|
||||
return False # Cancel — nothing further should happen
|
||||
|
||||
monkeypatch.setattr(task_editor_dialog_module, "TaskEditorDialog", _FakeDialog)
|
||||
|
||||
tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir)
|
||||
tab.kanban.edit_requested.emit(task["task_id"])
|
||||
|
||||
assert seen_task_ids == [task["task_id"]]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""EPIC R08-T14: GraphRenderer / GraphQaWidget / StructureGraphView shell,
|
||||
real Qt offscreen.
|
||||
|
||||
Scope note: a real directory SCAN (``GraphRenderer._scan``) runs on a
|
||||
``core.worker.AgentWorker`` QThread and had no existing test before this
|
||||
task either (grep confirms nothing under tests/ exercised
|
||||
``ui/structure_graph_view.py``). These tests drive ``_render()`` directly
|
||||
with a hand-built ``StructureGraph`` instead of a live scan — enough to
|
||||
prove the renderer <-> Q&A wiring (the actual R08-T14 deliverable) without
|
||||
needing a real codebase to walk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core.structure_graph import GEdge, GNode, StructureGraph # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _fake_graph(tmp_path):
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text("def hello():\n pass\n", encoding="utf-8")
|
||||
nodes = [
|
||||
GNode(id="n1", label="mod.py", kind="file", detail="a module", path=str(f)),
|
||||
GNode(id="n2", label="hello", kind="function", detail="says hi", path=str(f)),
|
||||
]
|
||||
edges = [GEdge(source="n1", target="n2", type="defines")]
|
||||
return StructureGraph(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
def test_structure_graph_view_builds(ctx):
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
assert view.renderer is not None
|
||||
assert view.qa is not None
|
||||
|
||||
|
||||
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
seen = []
|
||||
renderer.graph_rendered.connect(lambda: seen.append(1))
|
||||
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
|
||||
assert renderer.graph is graph
|
||||
assert len(renderer._node_items) == 2
|
||||
assert seen == [1]
|
||||
|
||||
|
||||
def test_render_ignores_a_stale_scan_result(ctx, tmp_path):
|
||||
"""Only the LATEST scan's result is ever rendered (no stale overwrite)."""
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
first = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": first, "pos": {}, "seq": renderer._scan_seq})
|
||||
renderer._scan_seq += 1 # a second scan started
|
||||
|
||||
stale = StructureGraph(nodes=[], edges=[])
|
||||
renderer._render({"graph": stale, "pos": {}, "seq": renderer._scan_seq - 1})
|
||||
|
||||
assert renderer.graph is first # stale result was dropped
|
||||
|
||||
|
||||
def test_node_selection_updates_the_qa_detail_panel(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
|
||||
node = graph.nodes[1]
|
||||
renderer.node_selected.emit(node)
|
||||
|
||||
assert "hello" in qa.detail.toPlainText()
|
||||
assert "says hi" in qa.detail.toPlainText()
|
||||
|
||||
|
||||
def test_candidate_file_paths_uses_selected_nodes_when_present(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
|
||||
# No selection -> every file node in the graph (both nodes share one
|
||||
# file here, so this also proves the path-dedup in _candidate_file_paths).
|
||||
assert qa._candidate_file_paths() == [graph.nodes[0].path]
|
||||
|
||||
renderer._node_items[0].setSelected(True)
|
||||
assert qa._candidate_file_paths() == [graph.nodes[0].path]
|
||||
|
||||
|
||||
def test_project_change_clears_the_qa_extraction_cache(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
qa._extract_cache = {"some/path.py": "cached text"}
|
||||
|
||||
renderer.project_changed.emit()
|
||||
|
||||
assert qa._extract_cache == {}
|
||||
|
||||
|
||||
def test_qa_collapse_resizes_the_shells_splitter(ctx):
|
||||
"""Exact pixel width is Qt's splitter-layout arithmetic, not this code's
|
||||
concern - what matters is that collapsing narrows the QA pane a lot
|
||||
(down near the strip's width) while the strip itself becomes visible and
|
||||
the total splitter width is conserved."""
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
before = view._split.sizes()
|
||||
|
||||
view.qa._set_collapsed(True)
|
||||
|
||||
after = view._split.sizes()
|
||||
assert after[1] <= CollapseStrip.WIDTH + 2
|
||||
assert view.qa.maximumWidth() == CollapseStrip.WIDTH + 2
|
||||
assert sum(after) == sum(before) # total width conserved, just redistributed
|
||||
|
||||
|
||||
def test_hide_event_clears_extracts(ctx):
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
view.qa._extract_cache = {"x": "y"}
|
||||
|
||||
from PySide6.QtGui import QHideEvent
|
||||
view.hideEvent(QHideEvent())
|
||||
|
||||
assert view.qa._extract_cache == {}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""EPIC R07-T05: AiTaskPlannerService — AI-generate + import, no Qt, no network.
|
||||
|
||||
``core/ai_task_planner.py::plan_tasks`` and ``core/task_import.py::
|
||||
import_tasks`` are exercised through a FakeProvider / real tmp files rather
|
||||
than reimplemented — this service is a seam, not a new planner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
_PLAN_REPLY = json.dumps({
|
||||
"tasks": [
|
||||
{"title": "Draft report", "description": "d", "task_type": "cowork",
|
||||
"priority": "medium", "schedule": {"enabled": False}}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
def test_plan_uses_the_constructor_injected_provider_factory():
|
||||
provider = FakeProvider([ScriptedTurn(text=_PLAN_REPLY)])
|
||||
service = AiTaskPlannerService(provider_factory=lambda: provider)
|
||||
|
||||
tasks = service.plan("Write a weekly report")
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["title"] == "Draft report"
|
||||
assert provider.call_count == 1
|
||||
|
||||
|
||||
def test_plan_prefers_an_explicit_provider_over_the_factory():
|
||||
factory_provider = FakeProvider([ScriptedTurn(text=_PLAN_REPLY)], strict=False)
|
||||
explicit_provider = FakeProvider([ScriptedTurn(text=_PLAN_REPLY)])
|
||||
service = AiTaskPlannerService(provider_factory=lambda: factory_provider)
|
||||
|
||||
service.plan("Write a weekly report", provider=explicit_provider)
|
||||
|
||||
assert explicit_provider.call_count == 1
|
||||
assert factory_provider.call_count == 0
|
||||
|
||||
|
||||
def test_plan_without_any_provider_raises_runtime_error():
|
||||
service = AiTaskPlannerService(provider_factory=None)
|
||||
with pytest.raises(RuntimeError):
|
||||
service.plan("Write a weekly report")
|
||||
|
||||
|
||||
def test_plan_stamps_attachments_onto_every_generated_task():
|
||||
two_tasks_reply = json.dumps({"tasks": [
|
||||
{"title": "A", "task_type": "cowork"},
|
||||
{"title": "B", "task_type": "cowork"},
|
||||
]})
|
||||
provider = FakeProvider([ScriptedTurn(text=two_tasks_reply)])
|
||||
service = AiTaskPlannerService(provider_factory=lambda: provider)
|
||||
|
||||
tasks = service.plan("do two things", file_paths=["a.txt"], links=["https://x"])
|
||||
|
||||
assert len(tasks) == 2
|
||||
for t in tasks:
|
||||
assert t["input"]["file_paths"] == ["a.txt"]
|
||||
assert t["input"]["links"] == ["https://x"]
|
||||
|
||||
|
||||
def test_import_file_delegates_to_core_task_import(tmp_path):
|
||||
csv_path = tmp_path / "tasks.csv"
|
||||
csv_path.write_text("title,task_type,priority\nMy Task,cowork,medium\n", encoding="utf-8")
|
||||
service = AiTaskPlannerService()
|
||||
|
||||
tasks = service.import_file(csv_path)
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["title"] == "My Task"
|
||||
|
||||
|
||||
def test_import_file_raises_value_error_on_unsupported_extension(tmp_path):
|
||||
bogus = tmp_path / "tasks.txt"
|
||||
bogus.write_text("nope", encoding="utf-8")
|
||||
service = AiTaskPlannerService()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service.import_file(bogus)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""EPIC R06-T02: atomic JSON writes + the WorkspaceRepository/
|
||||
ConversationRepository facades over core/projects.py and core/history.py.
|
||||
|
||||
The motivating bug: ``core/projects.py::save_project`` used to
|
||||
``path.write_text(json.dumps(...))`` — two syscalls, no atomicity. A failure
|
||||
between them must never leave a half-written file on disk; that is the one
|
||||
property these tests exist to pin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.infrastructure.persistence.json import (
|
||||
ConversationRepository,
|
||||
WorkspaceRepository,
|
||||
write_json,
|
||||
)
|
||||
from cowork_local.infrastructure.persistence.json.atomic_write import write_json as _write_json
|
||||
|
||||
|
||||
def test_write_json_round_trips(tmp_path):
|
||||
path = tmp_path / "a.json"
|
||||
write_json(path, {"hello": "world", "n": 3})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"hello": "world", "n": 3}
|
||||
|
||||
|
||||
def test_write_json_leaves_no_temp_file_behind(tmp_path):
|
||||
write_json(tmp_path / "a.json", {"x": 1})
|
||||
assert list(tmp_path.iterdir()) == [tmp_path / "a.json"]
|
||||
|
||||
|
||||
def test_a_failed_write_never_corrupts_the_existing_file(tmp_path, monkeypatch):
|
||||
"""The whole point of write-temp-then-replace: if the replace step blows
|
||||
up, the ORIGINAL file must still be there and still be readable — not
|
||||
truncated, not half-written."""
|
||||
path = tmp_path / "a.json"
|
||||
write_json(path, {"version": 1})
|
||||
|
||||
import cowork_local.infrastructure.persistence.json.atomic_write as mod
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise OSError("simulated crash between write and replace")
|
||||
|
||||
monkeypatch.setattr(mod.os, "replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
write_json(path, {"version": 2})
|
||||
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"version": 1}
|
||||
# the abandoned temp file was cleaned up, not left orphaned
|
||||
assert list(tmp_path.iterdir()) == [path]
|
||||
|
||||
|
||||
def test_workspace_repository_crud_round_trip(tmp_path):
|
||||
repo = WorkspaceRepository(tmp_path)
|
||||
project = repo.create("My Project", description="d")
|
||||
|
||||
assert [p.project_id for p in repo.list()] == [project.project_id]
|
||||
|
||||
project.description = "updated"
|
||||
repo.save(project)
|
||||
assert repo.get(project.project_id).description == "updated"
|
||||
|
||||
assert repo.delete(project.project_id) is True
|
||||
assert repo.get(project.project_id) is None
|
||||
|
||||
|
||||
def test_conversation_repository_crud_round_trip(tmp_path):
|
||||
repo = ConversationRepository(tmp_path)
|
||||
session_id = repo.new_session_id()
|
||||
path = repo.save("cowork", session_id, [{"role": "user", "content": "hi"}])
|
||||
|
||||
assert [c["session_id"] for c in repo.list()] == [session_id]
|
||||
|
||||
repo.rename(path, "Renamed")
|
||||
repo.set_pinned(path, True)
|
||||
data = repo.load(path)
|
||||
assert data["title"] == "Renamed"
|
||||
assert data["pinned"] is True
|
||||
|
||||
repo.delete(path)
|
||||
assert repo.list() == []
|
||||
@@ -0,0 +1,62 @@
|
||||
"""EPIC R05-T03/T04: ``core/code_agent.py::run_code`` used to gate tool calls
|
||||
with ``if name in (WRITE_TOOLS | MS365_WRITE_TOOLS): gate.request(...)``. This
|
||||
pins that the switch to ``ToolPolicyGateway`` still gates exactly the same
|
||||
calls: ``write_file`` (a WRITE tool) consults the gate; ``list_dir``
|
||||
(read-only) never does.
|
||||
|
||||
Runs the REAL engine (``run_code``) via :class:`FakeProvider`, same approach
|
||||
``tests/characterization/test_run_cowork.py`` uses for the Cowork engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from cowork_local.core.code_agent import run_code
|
||||
from cowork_local.core.tools import ToolContext
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
class _RecordingGate:
|
||||
def __init__(self, approve: bool):
|
||||
self.approve = approve
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool:
|
||||
self.calls.append(payload)
|
||||
return self.approve
|
||||
|
||||
|
||||
def _run(tmp_path, provider, gate):
|
||||
ctx = ToolContext(tmp_path)
|
||||
events: List[Dict[str, Any]] = []
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "do it"}]
|
||||
run_code(provider, messages, ctx, gate, events.append)
|
||||
return events
|
||||
|
||||
|
||||
def test_write_file_consults_the_gate_and_honors_rejection(tmp_path):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("write_file", {"path": "a.txt", "content": "hi"})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=False)
|
||||
events = _run(tmp_path, provider, gate)
|
||||
|
||||
assert len(gate.calls) == 1 and gate.calls[0]["name"] == "write_file"
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is False
|
||||
assert not (tmp_path / "a.txt").exists() # rejected, never actually written
|
||||
|
||||
|
||||
def test_read_only_tool_never_consults_the_gate(tmp_path):
|
||||
(tmp_path / "existing.txt").write_text("x", encoding="utf-8")
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("list_dir", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=False) # would reject if ever asked
|
||||
events = _run(tmp_path, provider, gate)
|
||||
|
||||
assert gate.calls == []
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is True
|
||||
@@ -0,0 +1,86 @@
|
||||
"""EPIC R05-T04: before this change, ``core/chat_agent.py::run_cowork`` called
|
||||
``extra_executor(name, args)`` directly for any MCP/connector tool — no
|
||||
permission check at all, regardless of the "confirm before running commands"
|
||||
setting. This pins the fix: an extra tool now goes through the same
|
||||
``ToolPolicyGateway`` as ``run_command``, using the conservative default
|
||||
capability (``UNKNOWN_SOURCE_CAPABILITIES``) since MCP tools carry no
|
||||
standard risk metadata.
|
||||
|
||||
Runs the real engine via :class:`FakeProvider`, matching
|
||||
``tests/characterization/test_run_cowork.py``'s approach.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from cowork_local.core.chat_agent import run_cowork
|
||||
from cowork_local.providers.base import ToolSpec
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
|
||||
class _RecordingGate:
|
||||
def __init__(self, approve: bool):
|
||||
self.approve = approve
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool:
|
||||
self.calls.append(payload)
|
||||
return self.approve
|
||||
|
||||
|
||||
_EXTRA_SPEC = ToolSpec(name="github__delete_repo", description="", parameters={"type": "object"})
|
||||
|
||||
|
||||
def _run(tmp_path, provider, gate, executed: List[str]):
|
||||
events: List[Dict[str, Any]] = []
|
||||
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
|
||||
|
||||
def extra_executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
executed.append(name)
|
||||
return {"ok": True, "output": "done"}
|
||||
|
||||
run_cowork(provider, messages, tmp_path, events.append, gate=gate,
|
||||
extra_tools=[_EXTRA_SPEC], extra_executor=extra_executor)
|
||||
return events
|
||||
|
||||
|
||||
def test_mcp_style_tool_is_rejected_without_ever_calling_the_executor(tmp_path):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("github__delete_repo", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=False)
|
||||
executed: List[str] = []
|
||||
events = _run(tmp_path, provider, gate, executed)
|
||||
|
||||
assert len(gate.calls) == 1 and gate.calls[0]["name"] == "github__delete_repo"
|
||||
assert executed == [] # rejected BEFORE the extra_executor ever ran
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is False
|
||||
|
||||
|
||||
def test_mcp_style_tool_runs_once_approved(tmp_path):
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("github__delete_repo", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
gate = _RecordingGate(approve=True)
|
||||
executed: List[str] = []
|
||||
events = _run(tmp_path, provider, gate, executed)
|
||||
|
||||
assert executed == ["github__delete_repo"]
|
||||
results = [e for e in events if e.get("type") == "tool_result"]
|
||||
assert results[0]["ok"] is True
|
||||
|
||||
|
||||
def test_no_gate_preserves_auto_run_for_extra_tools(tmp_path):
|
||||
"""``gate=None`` is Cowork's existing "no confirmation configured" state —
|
||||
must still auto-run, exactly like before this EPIC."""
|
||||
provider = FakeProvider([
|
||||
ScriptedTurn(tool_calls=[("github__delete_repo", {})]),
|
||||
ScriptedTurn(text="done"),
|
||||
])
|
||||
executed: List[str] = []
|
||||
events = _run(tmp_path, provider, None, executed)
|
||||
|
||||
assert executed == ["github__delete_repo"]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""EPIC R08-T13: DashboardQueryService — no Qt.
|
||||
|
||||
``core/usage_tracker.py::USAGE_DIR`` is a module-level constant (not
|
||||
injectable per-call except through an explicit ``directory=`` kwarg
|
||||
``load_events`` alone accepts) — this is a pre-existing testability gap the
|
||||
original ``ui/dashboard_tab.py`` also had (it had zero tests before this
|
||||
task). Monkeypatching the module attribute is what lets these tests write
|
||||
usage events without touching the real ``~/.cowork_local/usage/``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "usage"
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _write_event(usage_dir, day: date, **overrides):
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat",
|
||||
"provider": "anthropic", "model": "claude-sonnet-4-6",
|
||||
"in": 100, "out": 50, "cache": 0, "estimated": False,
|
||||
"account": "", "machine": "",
|
||||
}
|
||||
event.update(overrides)
|
||||
path = usage_dir / f"{day.isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def test_period_range_is_inclusive_end(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
start, end = query.period_range("week", 0)
|
||||
assert start <= end
|
||||
|
||||
|
||||
def test_summary_aggregates_events_in_range(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today, **{"in": 100, "out": 50})
|
||||
_write_event(usage_dir, today - timedelta(days=400), **{"in": 999, "out": 999}) # out of range
|
||||
query = DashboardQueryService(ctx)
|
||||
|
||||
summary = query.summary(today, today)
|
||||
|
||||
assert len(summary["events"]) == 1
|
||||
assert summary["stats"]["in"] == 100
|
||||
assert summary["stats"]["out"] == 50
|
||||
assert summary["total_cost"] >= 0
|
||||
|
||||
|
||||
def test_summary_empty_range_has_no_events(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
summary = query.summary(date(2020, 1, 1), date(2020, 1, 1))
|
||||
assert summary["events"] == []
|
||||
assert summary["stats"]["total"] == 0
|
||||
|
||||
|
||||
def test_pricing_returns_a_dict_with_currency(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
pricing = query.pricing()
|
||||
assert "currency" in pricing
|
||||
|
||||
|
||||
def test_chart_series_returns_points_for_the_granularity(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today)
|
||||
query = DashboardQueryService(ctx)
|
||||
|
||||
pts = query.chart_series("week", 0, "tokens")
|
||||
|
||||
assert len(pts) == 7 # week view = 7 days
|
||||
assert all(isinstance(p, tuple) and len(p) == 2 for p in pts)
|
||||
|
||||
|
||||
def test_budget_status_none_when_no_budget_set(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
assert query.budget_status() is None
|
||||
|
||||
|
||||
def test_set_budget_then_status_reflects_it(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query.set_budget(100.0, "USD")
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == pytest.approx(100.0)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""EPIC R06-T03: ExecutionWorkspace names the output-dir/scratch-dir split
|
||||
that already exists in ``core/chat_agent.py`` (``.scratch`` under the
|
||||
workspace root) without changing where anything lands."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
from cowork_local.infrastructure.filesystem.execution_workspace import ExecutionWorkspace
|
||||
|
||||
|
||||
def test_output_dir_is_the_workspace_root_itself(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
workspace = ExecutionWorkspace(session, turn_id="turn-1")
|
||||
|
||||
assert workspace.output_dir == tmp_path
|
||||
|
||||
|
||||
def test_scratch_dir_matches_the_existing_flat_convention(tmp_path):
|
||||
"""core/chat_agent.py::_cleanup_cowork_intermediates operates on
|
||||
``output_dir / ".scratch"`` with no per-turn subfolder — this must agree,
|
||||
or cleanup_scratch() would target a directory nothing ever wrote to."""
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
workspace = ExecutionWorkspace(session, turn_id="turn-1")
|
||||
|
||||
assert workspace.scratch_dir == tmp_path / ".scratch"
|
||||
|
||||
|
||||
def test_ensure_dirs_creates_both_folders(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path / "root")
|
||||
workspace = ExecutionWorkspace(session, turn_id="t")
|
||||
workspace.ensure_dirs()
|
||||
|
||||
assert workspace.output_dir.is_dir()
|
||||
assert workspace.scratch_dir.is_dir()
|
||||
|
||||
|
||||
def test_cleanup_scratch_removes_it_and_leaves_output_dir_alone(tmp_path):
|
||||
session = WorkspaceSession.unscoped(tmp_path)
|
||||
workspace = ExecutionWorkspace(session, turn_id="t")
|
||||
workspace.ensure_dirs()
|
||||
(workspace.scratch_dir / "helper.py").write_text("print(1)", encoding="utf-8")
|
||||
(workspace.output_dir / "deliverable.txt").write_text("done", encoding="utf-8")
|
||||
|
||||
workspace.cleanup_scratch()
|
||||
|
||||
assert not workspace.scratch_dir.exists()
|
||||
assert (workspace.output_dir / "deliverable.txt").exists()
|
||||
|
||||
|
||||
def test_cleanup_scratch_is_a_no_op_when_never_created(tmp_path):
|
||||
workspace = ExecutionWorkspace(WorkspaceSession.unscoped(tmp_path), turn_id="t")
|
||||
workspace.cleanup_scratch() # must not raise
|
||||
@@ -0,0 +1,76 @@
|
||||
"""EPIC R08-T12: pure helpers moved out of ui/folder_tab.py into
|
||||
application/workspaces/ (file_preview_helpers.py, ai_edit_output.py) — no Qt,
|
||||
directly unit-testable, unlike when they lived as private module functions
|
||||
inside the Qt widget file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import (
|
||||
parse_ai_output,
|
||||
split_code_block,
|
||||
)
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text,
|
||||
read_text,
|
||||
)
|
||||
|
||||
|
||||
def test_read_text_returns_file_contents(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
assert read_text(str(f)) == "hello"
|
||||
|
||||
|
||||
def test_read_text_on_missing_file_returns_a_note_not_raise(tmp_path):
|
||||
result = read_text(str(tmp_path / "missing.txt"))
|
||||
assert "could not read file" in result
|
||||
|
||||
|
||||
def test_is_probably_text_true_for_utf8(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hello world", encoding="utf-8")
|
||||
assert is_probably_text(str(f)) is True
|
||||
|
||||
|
||||
def test_is_probably_text_false_for_null_bytes(tmp_path):
|
||||
f = tmp_path / "a.bin"
|
||||
f.write_bytes(b"\x00\x01\x02")
|
||||
assert is_probably_text(str(f)) is False
|
||||
|
||||
|
||||
def test_split_code_block_extracts_fenced_block_and_summary():
|
||||
text = "Here is the change:\n\n```python\nprint('hi')\n```"
|
||||
content, summary = split_code_block(text)
|
||||
assert content == "print('hi')\n"
|
||||
assert summary == "Here is the change:"
|
||||
|
||||
|
||||
def test_split_code_block_no_fence_returns_none_and_full_text():
|
||||
content, summary = split_code_block("just prose, no code")
|
||||
assert content is None
|
||||
assert summary == "just prose, no code"
|
||||
|
||||
|
||||
def test_parse_ai_output_extracts_file_target():
|
||||
text = "FILE: new/thing.py\n```python\nx = 1\n```"
|
||||
target, content, summary, image_gens = parse_ai_output(text)
|
||||
assert target == "new/thing.py"
|
||||
assert content == "x = 1\n"
|
||||
assert image_gens == []
|
||||
|
||||
|
||||
def test_parse_ai_output_extracts_image_gen_directives():
|
||||
text = ("Adding an illustration.\n"
|
||||
"IMAGE_GEN: a red fox in a forest => assets/fox.png\n"
|
||||
"```html\n<img src='assets/fox.png'>\n```")
|
||||
target, content, summary, image_gens = parse_ai_output(text)
|
||||
assert image_gens == [("a red fox in a forest", "assets/fox.png")]
|
||||
assert "IMAGE_GEN" not in summary
|
||||
|
||||
|
||||
def test_parse_ai_output_no_directives_or_code_block():
|
||||
target, content, summary, image_gens = parse_ai_output("just an answer")
|
||||
assert target is None
|
||||
assert content is None
|
||||
assert image_gens == []
|
||||
assert summary == "just an answer"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""EPIC R06-T05: FileWorkspaceService gives File Explorer / AI Editor the
|
||||
same safe file operations the agent tool loop already has, via the SAME
|
||||
``core/tools.py::execute_tool`` dispatch (not a reimplementation)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.domain.workspaces import WorkspaceSession
|
||||
|
||||
|
||||
def _service(tmp_path) -> FileWorkspaceService:
|
||||
return FileWorkspaceService(WorkspaceSession.unscoped(tmp_path))
|
||||
|
||||
|
||||
def test_write_then_read_round_trips(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
written = service.write_file("notes.md", "# Hello")
|
||||
assert written["ok"] is True
|
||||
|
||||
read = service.read_preview("notes.md")
|
||||
assert read == {"ok": True, "output": "# Hello"}
|
||||
|
||||
|
||||
def test_list_tree_reflects_written_files(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.write_file("a.txt", "x")
|
||||
listing = service.list_tree()
|
||||
assert listing["ok"] is True and "a.txt" in listing["output"]
|
||||
|
||||
|
||||
def test_apply_edit_uses_the_context_anchored_replace(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.write_file("code.py", "value = 1\n")
|
||||
edited = service.apply_edit("code.py", "value = 1", "value = 2")
|
||||
assert edited["ok"] is True
|
||||
assert service.read_preview("code.py")["output"].strip() == "value = 2"
|
||||
|
||||
|
||||
def test_apply_edit_reports_ambiguous_match_like_the_agent_tool_does(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
service.write_file("code.py", "x = 1\nx = 1\n")
|
||||
edited = service.apply_edit("code.py", "x = 1", "x = 2")
|
||||
assert edited["ok"] is False
|
||||
assert "appears" in edited["output"]
|
||||
|
||||
|
||||
def test_path_escape_is_refused_not_a_crash(tmp_path):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(tmp_path / "outside.txt").write_text("secret", encoding="utf-8")
|
||||
service = FileWorkspaceService(WorkspaceSession.unscoped(workspace))
|
||||
|
||||
result = service.read_preview("../outside.txt")
|
||||
assert result["ok"] is False
|
||||
assert "outside the working folder" in result["output"]
|
||||
|
||||
|
||||
def test_write_preserves_subfolders_unlike_cowork_flatten_writes(tmp_path):
|
||||
"""File Explorer must not collapse a write into the workspace root the
|
||||
way Cowork's agent context does (flatten_writes=True there, False here)."""
|
||||
service = _service(tmp_path)
|
||||
service.write_file("sub/dir/file.txt", "content")
|
||||
assert (tmp_path / "sub" / "dir" / "file.txt").read_text(encoding="utf-8") == "content"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""EPIC R08-T14: graph_index_service.py — pure helpers moved out of
|
||||
ui/structure_graph_view.py, no Qt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
|
||||
|
||||
def test_extract_file_contents_reads_text_files(tmp_path):
|
||||
f = tmp_path / "a.py"
|
||||
f.write_text("print('hello')\n", encoding="utf-8")
|
||||
|
||||
block, cache = extract_file_contents([str(f)], {}, str(tmp_path))
|
||||
|
||||
assert "print('hello')" in block
|
||||
assert str(f) in cache
|
||||
assert cache[str(f)]
|
||||
|
||||
|
||||
def test_extract_file_contents_reuses_the_cache(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("original", encoding="utf-8")
|
||||
seed_cache = {str(f): "cached content, not re-read"}
|
||||
|
||||
block, cache = extract_file_contents([str(f)], seed_cache, str(tmp_path))
|
||||
|
||||
assert "cached content, not re-read" in block
|
||||
|
||||
|
||||
def test_extract_file_contents_skips_unreadable_paths_without_raising(tmp_path):
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
block, cache = extract_file_contents([str(missing)], {}, str(tmp_path))
|
||||
assert block == ""
|
||||
|
||||
|
||||
def test_extract_file_contents_respects_max_total_budget(tmp_path):
|
||||
f1 = tmp_path / "a.txt"
|
||||
f1.write_text("x" * 100, encoding="utf-8")
|
||||
f2 = tmp_path / "b.txt"
|
||||
f2.write_text("y" * 100, encoding="utf-8")
|
||||
|
||||
block, cache = extract_file_contents([str(f1), str(f2)], {}, str(tmp_path), max_total=50)
|
||||
|
||||
assert len(block) < 250 # bounded, not both files in full
|
||||
@@ -0,0 +1,106 @@
|
||||
"""EPIC R05-T05: the MCP connection lifecycle extracted out of
|
||||
``state.py::AppContext`` into :class:`McpToolSourceManager`.
|
||||
|
||||
Uses a fake connection (no real subprocess/asyncio loop) so these tests run in
|
||||
milliseconds and don't depend on any actual MCP server being installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from cowork_local.infrastructure.mcp import McpToolSourceManager
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
"""Stands in for ``core.mcp_client.McpServerConnection`` — tracks
|
||||
start/stop calls instead of spawning anything."""
|
||||
|
||||
instances: List["_FakeConnection"] = []
|
||||
|
||||
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
|
||||
env: Optional[Dict[str, str]] = None):
|
||||
self.name = name
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.env = env
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self._alive = True
|
||||
_FakeConnection.instances.append(self)
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopped = True
|
||||
self._alive = False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._alive
|
||||
|
||||
|
||||
def _manager() -> McpToolSourceManager:
|
||||
_FakeConnection.instances.clear()
|
||||
return McpToolSourceManager(connection_factory=_FakeConnection)
|
||||
|
||||
|
||||
def test_ensure_starts_once_and_caches_the_live_connection():
|
||||
mgr = _manager()
|
||||
first = mgr.ensure("github", "npx", ["-y", "github-mcp"])
|
||||
second = mgr.ensure("github", "npx", ["-y", "github-mcp"])
|
||||
|
||||
assert first is second # same connection reused, not a second subprocess
|
||||
assert len(_FakeConnection.instances) == 1
|
||||
assert first.started is True
|
||||
|
||||
|
||||
def test_two_concurrent_ensures_for_different_servers_dont_collide():
|
||||
mgr = _manager()
|
||||
a = mgr.ensure("server-a", "cmd-a")
|
||||
b = mgr.ensure("server-b", "cmd-b")
|
||||
assert a is not b
|
||||
assert {c.name for c in mgr.active()} == {"server-a", "server-b"}
|
||||
|
||||
|
||||
def test_ensure_restarts_when_the_cached_connection_died():
|
||||
mgr = _manager()
|
||||
first = mgr.ensure("flaky", "cmd")
|
||||
first.stop() # simulate the subprocess crashing
|
||||
assert mgr.is_alive("flaky") is False
|
||||
|
||||
second = mgr.ensure("flaky", "cmd")
|
||||
assert second is not first
|
||||
assert len(_FakeConnection.instances) == 2
|
||||
|
||||
|
||||
def test_a_server_that_fails_to_start_returns_none_and_isnt_cached():
|
||||
class _DyingConnection(_FakeConnection):
|
||||
def start(self) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
mgr = McpToolSourceManager(connection_factory=_DyingConnection)
|
||||
assert mgr.ensure("broken", "cmd") is None
|
||||
assert mgr.get("broken") is None
|
||||
|
||||
|
||||
def test_stop_removes_one_connection_without_touching_others():
|
||||
mgr = _manager()
|
||||
mgr.ensure("keep", "cmd")
|
||||
doomed = mgr.ensure("drop", "cmd")
|
||||
|
||||
mgr.stop("drop")
|
||||
|
||||
assert doomed.stopped is True
|
||||
assert mgr.get("drop") is None
|
||||
assert mgr.get("keep") is not None
|
||||
|
||||
|
||||
def test_stop_all_stops_every_connection_and_clears_the_cache():
|
||||
mgr = _manager()
|
||||
mgr.ensure("a", "cmd")
|
||||
mgr.ensure("b", "cmd")
|
||||
|
||||
mgr.stop_all()
|
||||
|
||||
assert all(c.stopped for c in _FakeConnection.instances)
|
||||
assert mgr.active() == []
|
||||
@@ -0,0 +1,164 @@
|
||||
"""EPIC R07-T02: ScheduleCalculator — pure due-time/cron math.
|
||||
|
||||
This is the one piece of scheduling logic core/tasks.py's own docstring
|
||||
claimed was "Qt-free so it can be unit-tested headlessly" but had NO unit
|
||||
test at all before this task (confirmed by grepping tests/ for
|
||||
"schedule_calculator"/"compute_next_run"/"cron" — nothing matched). These
|
||||
tests exercise domain/tasks/schedule_calculator.py directly, with no Qt, no
|
||||
filesystem, and fake holiday/cron callables so the module stays provably
|
||||
zero-I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.domain.tasks.schedule_calculator import ScheduleCalculator
|
||||
|
||||
|
||||
def _sched(**overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"run_at": "2026-08-24 09:00", # a Monday
|
||||
"repeat_type": "none",
|
||||
"cron_expression": None,
|
||||
"working_days_only": False,
|
||||
"skip_holidays": False,
|
||||
"holiday_country": "VN",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _task(**sched_overrides):
|
||||
return {"status": "scheduled", "schedule": _sched(**sched_overrides)}
|
||||
|
||||
|
||||
class _FakeCron:
|
||||
"""A cron stub that fires every day at a fixed hour:minute — enough to
|
||||
exercise the cron branch without depending on core/cron.py::Cron."""
|
||||
|
||||
def __init__(self, expression: str):
|
||||
if expression == "bad":
|
||||
raise ValueError("bad cron expression")
|
||||
self.hour, self.minute = 10, 0
|
||||
|
||||
def next_after(self, after: datetime) -> datetime:
|
||||
candidate = after.replace(hour=self.hour, minute=self.minute, second=0, microsecond=0)
|
||||
if candidate <= after:
|
||||
from datetime import timedelta
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
|
||||
|
||||
def _is_weekend_holiday(date_, country):
|
||||
# A deterministic fake: only 2026-08-29 (a Saturday) counts as a holiday.
|
||||
return date_.isoformat() == "2026-08-29"
|
||||
|
||||
|
||||
def test_daily_advances_by_one_day():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="daily")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 25, 9, 0)
|
||||
|
||||
|
||||
def test_weekly_advances_by_seven_days():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="weekly")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 31, 9, 0)
|
||||
|
||||
|
||||
def test_monthly_clamps_day_to_shorter_month():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(run_at="2026-01-31 09:00", repeat_type="monthly")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 1, 31, 9, 0))
|
||||
assert nxt == datetime(2026, 2, 28, 9, 0) # Feb 2026 has 28 days
|
||||
|
||||
|
||||
def test_one_shot_repeat_type_none_has_no_next_run():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="none")
|
||||
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
|
||||
|
||||
|
||||
def test_cron_without_injected_factory_returns_none():
|
||||
"""No make_cron wired in -> a cron schedule simply never produces a next
|
||||
run, instead of crashing the caller."""
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="cron", cron_expression="0 10 * * *")
|
||||
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
|
||||
|
||||
|
||||
def test_cron_uses_injected_factory():
|
||||
calc = ScheduleCalculator(make_cron=_FakeCron)
|
||||
task = _task(repeat_type="cron", cron_expression="0 10 * * *")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 24, 10, 0)
|
||||
|
||||
|
||||
def test_malformed_cron_expression_returns_none_not_raise():
|
||||
calc = ScheduleCalculator(make_cron=_FakeCron)
|
||||
task = _task(repeat_type="cron", cron_expression="bad")
|
||||
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
|
||||
|
||||
|
||||
def test_working_days_only_skips_weekend():
|
||||
calc = ScheduleCalculator()
|
||||
# 2026-08-28 is a Friday; +1 day (daily) would land on Saturday 08-29.
|
||||
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", working_days_only=True)
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
|
||||
assert nxt.weekday() < 5 # Monday 08-31, not the weekend
|
||||
|
||||
|
||||
def test_skip_holidays_uses_injected_is_holiday():
|
||||
calc = ScheduleCalculator(is_holiday=_is_weekend_holiday)
|
||||
# 2026-08-28 (Fri) + 1 day = 2026-08-29, which the fake marks a holiday.
|
||||
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", skip_holidays=True)
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 30, 9, 0) # skipped past the holiday
|
||||
|
||||
|
||||
def test_skip_holidays_without_injected_is_holiday_degrades_gracefully():
|
||||
calc = ScheduleCalculator() # no is_holiday wired in
|
||||
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", skip_holidays=True)
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 29, 9, 0) # no holiday check applied at all
|
||||
|
||||
|
||||
def test_shift_off_excluded_days_moves_weekend_forward():
|
||||
calc = ScheduleCalculator()
|
||||
sched = _sched(working_days_only=True)
|
||||
saturday = datetime(2026, 8, 29, 9, 0)
|
||||
shifted = calc.shift_off_excluded_days(saturday, sched)
|
||||
assert shifted.weekday() < 5
|
||||
assert shifted >= saturday
|
||||
|
||||
|
||||
def test_add_month_end_of_year_rolls_to_january():
|
||||
calc = ScheduleCalculator()
|
||||
assert calc.add_month(datetime(2026, 12, 15, 9, 0)) == datetime(2027, 1, 15, 9, 0)
|
||||
|
||||
|
||||
def test_due_tasks_filters_by_status_enabled_and_run_at():
|
||||
calc = ScheduleCalculator()
|
||||
now = datetime(2026, 8, 27, 12, 0)
|
||||
due_now = _task(run_at="2026-08-27 09:00")
|
||||
due_now["title"] = "due"
|
||||
future = _task(run_at="2026-08-28 09:00")
|
||||
future["title"] = "future"
|
||||
disabled = _task(run_at="2026-08-27 09:00", enabled=False)
|
||||
disabled["title"] = "disabled"
|
||||
not_scheduled = _task(run_at="2026-08-27 09:00")
|
||||
not_scheduled["title"] = "backlog"
|
||||
not_scheduled["status"] = "backlog"
|
||||
|
||||
result = calc.due_tasks([due_now, future, disabled, not_scheduled], now)
|
||||
assert [t["title"] for t in result] == ["due"]
|
||||
|
||||
|
||||
def test_due_tasks_empty_when_no_tasks():
|
||||
calc = ScheduleCalculator()
|
||||
assert calc.due_tasks([], datetime(2026, 8, 27, 12, 0)) == []
|
||||
@@ -0,0 +1,191 @@
|
||||
"""EPIC R07-T04: TaskApplicationService — CRUD + dispatch rules, no Qt.
|
||||
|
||||
Everything here used to be exercised only by driving the real
|
||||
``ui/schedule_task_tab.py`` widget (a QListWidget drag gesture, a QMenu
|
||||
click). These tests drive the same rules directly through the service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.scheduling.task_application_service import (
|
||||
TaskApplicationService,
|
||||
)
|
||||
from cowork_local.infrastructure.persistence.json import TaskRepository
|
||||
|
||||
|
||||
def _service(tmp_path, run_now=None):
|
||||
return TaskApplicationService(TaskRepository(tmp_path), run_now=run_now)
|
||||
|
||||
|
||||
def _new_saved_task(repo: TaskRepository, **overrides):
|
||||
task = repo.create("T", **overrides)
|
||||
repo.save(task)
|
||||
return task
|
||||
|
||||
|
||||
def test_run_now_rejects_manual_task_type(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="manual")
|
||||
service = TaskApplicationService(repo, run_now=lambda tid: True)
|
||||
|
||||
result = service.run_now(task["task_id"])
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == "manual_task"
|
||||
|
||||
|
||||
def test_run_now_without_scheduler_wired_reports_no_scheduler(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="cowork")
|
||||
service = TaskApplicationService(repo, run_now=None)
|
||||
|
||||
result = service.run_now(task["task_id"])
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == "no_scheduler"
|
||||
|
||||
|
||||
def test_run_now_delegates_to_injected_scheduler(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="cowork")
|
||||
seen = []
|
||||
service = TaskApplicationService(repo, run_now=lambda tid: seen.append(tid) or True)
|
||||
|
||||
result = service.run_now(task["task_id"])
|
||||
|
||||
assert result.ok is True
|
||||
assert seen == [task["task_id"]]
|
||||
|
||||
|
||||
def test_run_now_missing_task_reports_not_found(tmp_path):
|
||||
service = _service(tmp_path, run_now=lambda tid: True)
|
||||
result = service.run_now("does-not-exist")
|
||||
assert result.ok is False
|
||||
assert result.reason == "not_found"
|
||||
|
||||
|
||||
def test_duplicate_saves_a_copy_with_fresh_identity(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, description="d")
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
dup = service.duplicate(task["task_id"])
|
||||
|
||||
assert dup is not None
|
||||
assert dup["task_id"] != task["task_id"]
|
||||
assert dup["description"] == "d"
|
||||
assert repo.get(dup["task_id"]) is not None # actually persisted, not just returned
|
||||
|
||||
|
||||
def test_toggle_pause_then_resume_goes_to_backlog(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
paused = service.toggle_pause(task["task_id"])
|
||||
assert paused["status"] == "paused"
|
||||
|
||||
resumed = service.toggle_pause(task["task_id"])
|
||||
assert resumed["status"] == "backlog"
|
||||
|
||||
|
||||
def test_delete_reports_whether_the_task_existed(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
assert service.delete(task["task_id"]) is True
|
||||
assert repo.get(task["task_id"]) is None
|
||||
assert service.delete(task["task_id"]) is False # already gone
|
||||
|
||||
|
||||
def test_bulk_delete_counts_only_tasks_that_existed(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
a = _new_saved_task(repo)
|
||||
b = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
count = service.bulk_delete([a["task_id"], b["task_id"], "ghost-id"])
|
||||
|
||||
assert count == 2
|
||||
assert repo.list() == []
|
||||
|
||||
|
||||
def test_move_to_status_running_task_is_blocked(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
task["status"] = "running"
|
||||
repo.save(task)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "backlog")
|
||||
|
||||
assert result.blocked is True
|
||||
assert repo.get(task["task_id"])["status"] == "running" # untouched
|
||||
|
||||
|
||||
def test_move_to_status_running_lane_dispatches_run_now(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="cowork")
|
||||
seen = []
|
||||
service = TaskApplicationService(repo, run_now=lambda tid: seen.append(tid) or True)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "running")
|
||||
|
||||
assert result.ran_now is True
|
||||
assert result.run_now_result.ok is True
|
||||
assert seen == [task["task_id"]]
|
||||
|
||||
|
||||
def test_move_to_status_done_disables_the_schedule(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
task["schedule"]["enabled"] = True
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
repo.save(task)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "done")
|
||||
|
||||
assert result.task["status"] == "done"
|
||||
assert result.task["schedule"]["enabled"] is False
|
||||
assert repo.get(task["task_id"])["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_move_to_status_scheduled_without_run_at_needs_schedule(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo) # fresh task has schedule.run_at = None
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "scheduled")
|
||||
|
||||
assert result.needs_schedule is True
|
||||
assert repo.get(task["task_id"])["status"] == "scheduled"
|
||||
assert repo.get(task["task_id"])["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_move_to_status_scheduled_with_run_at_enables_it(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
repo.save(task)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "scheduled")
|
||||
|
||||
assert result.needs_schedule is False
|
||||
assert repo.get(task["task_id"])["schedule"]["enabled"] is True
|
||||
|
||||
|
||||
def test_move_to_status_plain_change(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "backlog")
|
||||
|
||||
assert result.task["status"] == "backlog"
|
||||
|
||||
|
||||
def test_move_to_status_missing_task_returns_none(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
assert service.move_to_status("does-not-exist", "backlog") is None
|
||||
@@ -0,0 +1,70 @@
|
||||
"""EPIC R07-T01: TaskRepository + core/tasks.py::save_task atomic write.
|
||||
|
||||
The motivating bug: ``core/tasks.py::save_task`` used to
|
||||
``path.write_text(json.dumps(...))`` — two syscalls, no atomicity, same class
|
||||
of bug already fixed for projects/conversations at R06-T02. A failure between
|
||||
the write and the replace must never leave a half-written task JSON file on
|
||||
disk; that is the one property the crash-injection test exists to pin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.tasks import new_task
|
||||
from cowork_local.infrastructure.persistence.json import TaskRepository
|
||||
|
||||
|
||||
def test_task_repository_crud_round_trip(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = repo.create("My Task")
|
||||
repo.save(task)
|
||||
|
||||
assert [t["task_id"] for t in repo.list()] == [task["task_id"]]
|
||||
assert repo.get(task["task_id"])["title"] == "My Task"
|
||||
|
||||
task["title"] = "Renamed"
|
||||
repo.save(task)
|
||||
assert repo.get(task["task_id"])["title"] == "Renamed"
|
||||
|
||||
repo.delete(task["task_id"])
|
||||
assert repo.get(task["task_id"]) is None
|
||||
assert repo.list() == []
|
||||
|
||||
|
||||
def test_task_repository_duplicate_keeps_config_resets_identity(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = repo.create("Original", description="d")
|
||||
repo.save(task)
|
||||
|
||||
dup = repo.duplicate(task)
|
||||
repo.save(dup)
|
||||
|
||||
assert dup["task_id"] != task["task_id"]
|
||||
assert dup["description"] == "d"
|
||||
assert {t["task_id"] for t in repo.list()} == {task["task_id"], dup["task_id"]}
|
||||
|
||||
|
||||
def test_save_task_never_corrupts_existing_file_on_crash(tmp_path, monkeypatch):
|
||||
"""Same guarantee as ``test_atomic_write_and_repositories.py``'s crash
|
||||
test, exercised through ``core/tasks.py::save_task`` directly (not just
|
||||
through the repository) since that is the function every existing
|
||||
scheduler/executor call site still uses."""
|
||||
from cowork_local.core.tasks import save_task, task_path
|
||||
|
||||
task = new_task("Stable")
|
||||
save_task(task, tmp_path)
|
||||
|
||||
import cowork_local.infrastructure.persistence.json.atomic_write as mod
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise OSError("simulated crash between write and replace")
|
||||
|
||||
monkeypatch.setattr(mod.os, "replace", boom)
|
||||
task["title"] = "Corrupted?"
|
||||
with pytest.raises(OSError):
|
||||
save_task(task, tmp_path)
|
||||
|
||||
on_disk = json.loads(task_path(task["task_id"], tmp_path).read_text(encoding="utf-8"))
|
||||
assert on_disk["title"] == "Stable"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""EPIC R07-T03: TaskScheduler <-> clock wiring, entirely through
|
||||
tests/fakes/fake_clock.py::FakeClock — no real QTimer, no Qt event loop.
|
||||
|
||||
Scope note: this only exercises the clock injection seam (start arms+starts
|
||||
the clock with `tick`, stop stops it), not the full dispatch/execution
|
||||
pipeline (`_start` -> `AgentWorker` -> `execute_task`), which needs a real
|
||||
``ctx``/provider and is exactly the kind of Qt-adjacent, thread-heavy path
|
||||
better left to an offscreen integration test if/when R08 touches this file
|
||||
again — recorded here rather than silently left untested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.core.task_scheduler import TICK_MS, TaskScheduler
|
||||
from tests.fakes import FakeClock
|
||||
|
||||
|
||||
def test_start_arms_and_starts_the_injected_clock(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
ticks = []
|
||||
# Instance-attribute override, set BEFORE start(): TaskScheduler.start()
|
||||
# reads `self.tick`, which Python resolves to this override rather than
|
||||
# the class method, so we can count calls without a real due task/ctx.
|
||||
scheduler.tick = lambda: ticks.append(1)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
assert clock.running is True
|
||||
assert clock.interval_ms == TICK_MS
|
||||
assert ticks == [1] # the catch-up tick() call at startup
|
||||
|
||||
|
||||
def test_clock_fire_drives_another_tick(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
ticks = []
|
||||
scheduler.tick = lambda: ticks.append(1)
|
||||
scheduler.start()
|
||||
|
||||
clock.fire()
|
||||
|
||||
assert ticks == [1, 1]
|
||||
|
||||
|
||||
def test_stop_stops_the_clock(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
scheduler.tick = lambda: None
|
||||
scheduler.start()
|
||||
|
||||
scheduler.stop()
|
||||
|
||||
assert clock.running is False
|
||||
|
||||
|
||||
def test_fire_after_stop_does_not_call_tick(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
ticks = []
|
||||
scheduler.tick = lambda: ticks.append(1)
|
||||
scheduler.start()
|
||||
scheduler.stop()
|
||||
|
||||
clock.fire()
|
||||
|
||||
assert ticks == [1] # only the startup catch-up tick, nothing after stop
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user