Compare commits

...
Author SHA1 Message Date
vudt15andClaude Sonnet 5 a8c6b5c20a docs(refactor): merge Team Hoa R05/R06 and R07/R08 reports into one
Replaces BaoCao_TeamHoa_R05_R06.md and BaoCao_TeamHoa_R07_R08.md with a
single BaoCao_TeamHoa_R05_R08.md covering all 4 EPICs (19/19 tasks) in
Team Hoa's scope, and updates the checklist's report links accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:06:18 +09:00
vudt15andClaude Sonnet 5 1efa1d29d1 docs(refactor): add the Team Hoa completion report for R07/R08
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:58:08 +09:00
vudt15andClaude Sonnet 5 0e51356a7d feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView
Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).

- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
  {kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
  ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
  shell. Kanban CRUD/drag-drop now goes through
  application/scheduling/task_application_service.py (R07-T04) instead of
  ~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
  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 + folder_tab.py shell.
  Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
  zero production call sites (confirmed by grep); every plain-text write
  (save/create/write_content) now goes through it, gaining path
  containment and a Python-syntax warning the original code never had.
  Pure helpers (_read_text, _is_probably_text, _pptx_available,
  _split_code_block, _parse_ai_output) moved to
  application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
  {token_usage_card_widget,usage_chart_widget,habits_widget}.py +
  dashboard_tab.py shell, backed by a new
  application/monitoring/dashboard_query_service.py (pricing/period/
  summary queries the three widgets used to each recompute separately).
  Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
  presentation/graph/{graph_scene_items,graph_renderer,
  graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
  shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
  moved to application/workspaces/graph_index_service.py (pure Python).
  Renderer and Q&A panel talk only through signals
  (node_selected/graph_rendered/raw_json_ready/project_changed) - neither
  imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
  duplicated (folder_tab imported it FROM structure_graph_view.py) - now
  one shared flag instead of one screen importing another screen's module.

All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).

pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:55:32 +09:00
vudt15andClaude Sonnet 5 69ab8e125b feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services
Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only
(R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's).

- R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps
  core/tasks.py's CRUD; core/tasks.py::save_task now writes through
  atomic_write.write_json (same durability fix as R06-T02, save_task was
  still doing a plain write_text).
- R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the
  cron/interval/daily/weekly/monthly due-time math extracted from
  core/tasks.py, pure Python with is_holiday/make_cron injected so domain/
  never imports core (ADR-001 I2). core/tasks.py keeps its old function
  names as thin wrappers so every existing caller is unchanged. This was
  previously untested; now has its own unit suite.
- R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps
  the QTimer TaskScheduler used to own directly, injected via a new
  `clock=` constructor param (defaults to a real one). Originally planned
  at platform/qt/... ; moved after confirming that name shadows the
  stdlib platform module (used by core/windows_sandbox_vm.py,
  core/appcontainer_sandbox.py) whenever the repo root is on sys.path.
  tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick
  with no Qt event loop.
- R07-T04: application/scheduling/task_application_service.py centralizes
  run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop
  business rules (move_to_status), currently only reachable by driving
  the real ui/schedule_task_tab.py widget.
- R07-T05: application/scheduling/ai_task_planner_service.py wraps
  core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks
  as a seam, plus the attachment-stamping step that used to only exist
  inside the AI-create dialog's worker closure.

pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline,
unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md).
scripts/check_imports.py: PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:27:53 +09:00
vudt15andClaude Sonnet 5 8ab29800db docs(refactor): add the Team Hoa completion report for R05/R06
Mirrors docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md's structure: per-EPIC
results, test evidence, the two real bugs found and fixed, secondary
improvements, open items needing another team's sign-off, untested scope,
and what's next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:30:52 +09:00
vudt15andClaude Sonnet 5 cf542b7416 feat(R06): workspace session snapshot, atomic persistence, history-dir race fix
EPIC R06 (Team Hoa) - workspace/filesystem isolation, no cross-project
mutable state.

R06-T01 domain/workspaces/workspace_session.py
  WorkspaceSession - project_id/workspace_root/sandbox_dir/allowed_paths
  frozen snapshot + is_allowed(path), same "capture once at submit time"
  shape as R04's ConversationExecutionRequest.

R06-T02 infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py
  Real bug fixed: core/projects.py::save_project and core/history.py's
  save_conversation/rename_conversation/set_pinned did a plain
  path.write_text(json.dumps(...)) - two syscalls, no atomicity. A crash
  between them leaves a half-written file that load_project/load_conversation
  then silently treat as "missing". All four now write through
  atomic_write.write_json (temp file + os.replace). WorkspaceRepository/
  ConversationRepository are thin object-shaped facades over the same
  (now-atomic) functions, for future application-layer callers.
  NOTE: atomic_write.py is deliberately NOT named atomic_json_file.py -
  R02-T01 (Team Nam) claims that filename for the same purpose app-wide;
  see the checklist for the consolidation TODO.

R06-T03 infrastructure/filesystem/execution_workspace.py
  ExecutionWorkspace names the output_dir/scratch_dir split that already
  exists (core/chat_agent.py's flat workspace_root/.scratch) - does not
  move anything.

R06-T04 ui/chat_panel.py
  The actual race: ChatPanel._persist_session (saves a BACKGROUND turn's
  conversation) resolved its save directory via a live
  self.ctx.config.history_dir() read at save time. ui/workspace_tab.py::
  _load_current mutates that same config field on every project switch, so
  a turn still running when the user switched projects got saved into the
  NEW project's history folder. Fixed by adding "home_history_dir" to the
  per-turn ctx dict (same "home_*" snapshot convention already used for
  session id/messages/title), captured at submit time. Verified with a real
  offscreen-Qt test, not just a unit double:
  tests/integration/test_history_dir_race.py.

R06-T05 application/workspaces/file_workspace_service.py
  FileWorkspaceService - the File Explorer / AI Editor entry point for the
  same safe read/write/edit operations the agent tool loop has, by calling
  core/tools.py::execute_tool directly (same dispatch, same ToolContext
  containment, same audit log) rather than reimplementing any of it.

New tests: tests/unit/test_workspace_session.py,
test_atomic_write_and_repositories.py, test_execution_workspace.py,
test_file_workspace_service.py, tests/integration/test_history_dir_race.py
(29 new tests, incl. 2 real offscreen-Qt integration tests).

Suite: 283 passed, 4 pre-existing failures unrelated to R05/R06 (see
checklist). check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:34:57 +09:00
vudt15andClaude Sonnet 5 ae4fe72b2e feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager
EPIC R05 (Team Hoa) - one security/approval path for every tool call.

R05-T01 domain/tools/{tool_descriptor,tool_registry}.py
  ToolCapability (READ/WRITE/EXECUTE/NETWORK, composable) + ToolDescriptor +
  ToolRegistry, replacing three independently-maintained gating lists
  (core/tools.py::WRITE_TOOLS, code_agent.py's WRITE_TOOLS|MS365_WRITE_TOOLS,
  chat_agent.py's literal ("run_command","install_package") tuple) with one
  capability lookup.

R05-T02 infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py
  core/tools.py's execute_tool if/elif chain split into per-concern modules.
  core/tools.py is now a strangler-fig shim: re-exports ToolContext/ToolError,
  dispatches through a {name: handler} dict built from the split modules.
  core/tools.py: 566 -> 291 lines.

R05-T03 application/conversations/tool_policy_gateway.py
  ToolPolicyGateway.allow(name, gate, payload) - capability-driven ALLOW vs
  ask-the-gate decision. Wired into both chat_agent.py::run_cowork and
  code_agent.py::run_code, replacing their separate hand-rolled checks.
  Verified equivalent to the old hardcoded sets by test.

R05-T04 (behavior change, not just refactor)
  MCP/connector tools (core/mcp_client.py, core/ext_connectors.py) reached
  chat_agent.py via extra_executor(name, args) with NO permission check at
  all. They are now tagged with a conservative default capability
  (WRITE|EXECUTE|NETWORK - no MCP tool self-declares risk) and routed through
  the SAME ToolPolicyGateway as built-ins. When "confirm before running
  commands" is on, MCP/connector calls now prompt like run_command already
  did - a real gap closed, and a user-visible change worth calling out.

R05-T05 infrastructure/mcp/mcp_source_manager.py
  McpToolSourceManager extracts the connection cache/lock/start-or-skip
  lifecycle out of state.py::AppContext (_mcp_connections/_conn_lock) into a
  standalone, directly-testable class. AppContext.build_mcp_tools and
  _ms365_builtin_connection now call ensure()/stop(); _ext_connections
  (unified Connectors) is out of scope for this task and keeps its own lock.

New tests: tests/unit/test_tool_registry_and_policy.py,
test_code_agent_tool_policy.py, test_cowork_extra_tool_policy.py,
test_mcp_source_manager.py (26 new tests).

Suite: 254 passed, 4 pre-existing failures unrelated to R05 (2 EPIC R02
config-security, 2 environment-dependent routing tests - see checklist).
check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:20:57 +09:00
anhtnm1andClaude Opus 5 6d3217e0b5 docs(refactor): add the Team Duy completion report for R01/R03/R04
docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md records what was delivered against
each of the 16 tasks, the measured evidence (243 tests, 218 of them in 1.22s;
check_imports PASS; no production file over 400 LOC), the three real defects
found while working - the routing_application() deadlock, the swallowed
"notice" event, and the suite silently testing a different checkout - plus the
six open decisions and, explicitly, what was NOT tested (no manual app launch,
no real provider traffic, tools/check_*.py not run).

Refactoring_Checklist.md now links to it from the progress block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:58:36 +09:00
anhtnm1andClaude Opus 5 67b8d2edbb docs(refactor): correct the Team Duy scope block in the checklist
The previous commit recorded Team Duy as owning R01/R02/R04/R10. That is wrong.
Feature_Architecture_Proposal.md line 7 and DeltaTeam_prompt.md line 17 both
state R01, R03, R04, R08 (Chat UI) and R10; R02 belongs to Team Nam, which is
also who owns the two failing config-security tests.

The completed work itself (R01, R03, R04) was already correct and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:52:40 +09:00
anhtnm1andClaude Opus 5 15e1d3eb65 test(R03/R04): cover the three code paths that were changed but never executed
Verification gap closed. The suite proved the new services correct in isolation,
but three paths I had modified had no test actually running them:

tests/integration/test_task_executor_flow.py (7 tests)
  The Schedule Task path after R04-T05. Pins that History is still re-saved from
  the LIVE message list mid-run (the reason begin_turn() exists - the pre-turn
  copy would have frozen progress at the first user message), that update_plan
  tracking still reports an unfinished checklist, and that a failed run still
  raises so execute_task writes error.txt.

tests/integration/test_routing_surfaces.py (11 tests)
  Real offscreen CoworkTab/Co4ETab/FolderTab calling the shared routing service:
  correct surface key per screen, Auto switches, Off does not consult the engine,
  Manual switches only on approval, a pinned Admin agent still wins, and AI-Edit
  still pins TaskType.CODING. Also pins the field contract ui/routing_toggle.py
  reads off RoutingDecision (from_model/to_model as provider/model keys) - a
  rename there would only fail inside a modal dialog.

Also updates docs/refactor/Refactoring_Checklist.md: the 16 completed R01/R03/R04
tasks, the Team Duy daily rows, and a status block recording the measured
numbers, the scope correction (team owns R01/R02/R04/R10), and what is still
outstanding.

Suite: 243 passed, 2 pre-existing failures (EPIC R02). Fast suite (unit +
contracts + characterization + routing): 218 passed in 1.16s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:45:05 +09:00
anhtnm1andClaude Opus 5 a53163ebaf feat(R04): immutable turn snapshot, typed agent events, conversation service
EPIC R04 (Team Duy) - the turn lifecycle leaves the widget.

R04-T01 domain/agents/conversation_execution_request.py
  Frozen snapshot of one turn, captured on the UI thread at submit time. The
  job closure used to read widget/workspace state from inside the worker
  thread, so a turn could run on a mix of submit-time and later state
  depending on thread timing.
R04-T02 domain/agents/agent_event.py
  13 frozen event types replacing untyped emit() dicts, with a two-way bridge
  so existing widgets keep consuming the legacy shape until EPIC R08. Adds
  TurnCompletedEvent - the end-of-turn signal the engine never had, which is
  why a cancelled turn and a failed turn look identical to the UI today.
R04-T03 application/conversations/conversation_application_service.py
  Runs a turn from a request and reports typed events. Never raises across the
  worker boundary; TurnResult.raise_if_failed() preserves the existing
  exception-based failure path. begin_turn()/execute_turn() expose the live
  message list for callers that autosave history mid-run.
R04-T04 ui/cowork_tab.py::build_job -> snapshot + service.
R04-T05 core/task_executors.py::_run_agent -> same service (was a second,
  slightly different assembly of the same call).

Caught while wiring the bridge: the first event vocabulary had no "notice"
event, so Agent Security warnings and auto-compaction notices would have been
silently swallowed. Added NoticeEvent plus a test that scans the engine sources
for emit() tags and fails when one has no typed counterpart.

New: tests/integration/ - real offscreen CoworkTab running a scripted turn end
to end (7 tests), including a characterisation of the extra provider call Agent
Security spends reviewing each request.

Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:32:14 +09:00
anhtnm1andClaude Opus 5 96bec976e7 feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.

R03-T01 tests/contracts/test_providers.py
  29 contract tests every provider must satisfy: canonical assistant message,
  streamed text == returned content, reasoning never joins the answer, parsed
  tool arguments, ProviderError for every failure. Real adapters exercised
  offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
        infrastructure/providers/provider_registry.py
  Provider facts declared once (was split across providers/factory.py,
  DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
  descriptor id onto the instance, so ollama/github_copilot/codex usage is no
  longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
  Pure-Python routing policy with four modes: Off, Auto, Manual and the new
  Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
  protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
  Three near-identical routing copies (~40 lines each) replaced by a call to
  ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
  in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
  Token usage extracted from both providers into UsageEvent + UsageEventSink.
  Estimation pinned against core.usage_tracker so no recorded number changes.

Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.

Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:22:28 +09:00
anhtnm1andClaude Opus 5 bbc09f628a feat(R01): architecture foundation, offline fakes and characterization net
EPIC R01 (Team Duy) - safety net before the parallel refactor starts.

R01-T01 docs/architecture/ADR-001-layered-architecture.md
  4-tier boundaries, allowed dependency directions, invariants I1-I6 and
  the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
  Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
  AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
  imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
  13 snapshot tests pinning run_cowork's current observable contract before
  EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
  Import-graph scan: 43 unimported modules verified down to 6 genuinely
  dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.

tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.

Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:05:50 +09:00
150 changed files with 15568 additions and 4583 deletions
+3 -3
View File
@@ -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"
+12
View File
@@ -0,0 +1,12 @@
"""Application layer - pure Python use-case orchestration.
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
here answers "what has to happen, in what order" for one use case - route a
turn, run a conversation - without knowing whether a human, a scheduler or a
test triggered it.
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
back up through plain-Python callbacks; turning those into Qt signals is the
presentation layer's job.
"""
+10
View File
@@ -0,0 +1,10 @@
"""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", "ToolPolicyGateway", "ConfirmGate"]
@@ -0,0 +1,328 @@
"""ConversationApplicationService - the turn lifecycle, outside the widget (R04-T03).
What this replaces
------------------
The lifecycle of one Cowork turn is currently spread across a closure inside
``ui/cowork_tab.py::build_job`` and a second, near-identical assembly inside
``core/task_executors.py::_run_agent``. Both:
* read live UI/config state from a worker thread,
* build the provider, the MCP tool set and the project context by hand,
* call ``core.chat_agent.run_cowork`` with a dozen positional-ish arguments,
* consume untyped event dicts.
Two copies means a fix to one path (say, promoting output files on failure)
silently misses the other. This service is the single implementation: it takes
an immutable :class:`ConversationExecutionRequest`, runs the turn, and reports
typed :class:`AgentEvent` objects.
What it deliberately does NOT do
--------------------------------
It does not re-implement the agent loop. ``run_cowork`` stays the engine
(strangler fig, ADR-001 section 4) and keeps its characterization tests
(``tests/characterization/test_run_cowork.py``). This layer owns the parts that
were tangled into the UI: assembling the call, translating events, and giving a
turn a well-defined end.
Pure Python: no Qt import, no config access. Everything it needs arrives through
constructor callbacks, so the same service runs a turn from a chat panel, from
the scheduler, or from a test.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import (
AgentEvent,
ErrorEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
)
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
logger = logging.getLogger("cowork_local.conversations")
# Presentation/scheduler supplies these. Kept as plain callables (not objects)
# so a test can wire the service with three lambdas.
EventCallback = Callable[[AgentEvent], None]
CancelFn = Callable[[], bool]
ProviderFactory = Callable[[str, str], Any] # (provider_id, model) -> Provider
ToolSourceFactory = Callable[[], Tuple[Any, Any]] # () -> (extra_tools, extra_executor)
GateFactory = Callable[[ConversationExecutionRequest], Any] # -> PermissionGate or None
@dataclass
class TurnResult:
"""What a finished turn produced.
``messages`` is the conversation AFTER the turn (system prompt inserted,
assistant and tool messages appended) - the caller persists this as the new
history. ``final_text`` is the visible answer, reasoning excluded.
"""
request: ConversationExecutionRequest
messages: List[Dict[str, Any]] = field(default_factory=list)
events: List[AgentEvent] = field(default_factory=list)
final_text: str = ""
cancelled: bool = False
error: str = ""
# The original exception, kept alongside its message so a caller that needs
# to preserve legacy failure handling can re-raise the SAME object rather
# than a lookalike (SecurityBlocked, for instance, carries context that a
# re-wrapped RuntimeError would lose).
exception: Optional[BaseException] = None
@property
def ok(self) -> bool:
"""True when the turn completed without an error and without a Stop."""
return not self.error and not self.cancelled
def raise_if_failed(self) -> None:
"""Re-raise the turn's failure, if any.
Callers that already have failure handling built around an exception
(the Qt worker turns one into its ``failed`` signal) use this to keep
that path intact while still getting a TurnResult on success."""
if self.exception is not None:
raise self.exception
def output_dir(self) -> Optional[Path]:
"""This turn's output folder, or None when it could not write files."""
return Path(self.request.output_dir) if self.request.output_dir else None
class ConversationApplicationService:
"""Runs one agent turn from an immutable request.
Args:
provider_factory: ``(provider_id, model) -> Provider``. Production passes
``AppContext.build_provider_for``; tests pass a lambda returning a
:class:`FakeProvider`.
tool_source: ``() -> (extra_tools, extra_executor)`` for MCP/connector
tools. Optional - a turn with no external tools passes nothing.
gate_factory: ``(request) -> PermissionGate | None``, consulted when the
request asks to confirm commands. Optional for the same reason.
runner: the turn engine. Defaults to ``core.chat_agent.run_cowork``,
imported lazily so this module stays importable (and testable)
without pulling in the whole legacy tool stack.
security_config: the app config the security layers read. ``None``
disables them, which is what headless callers already rely on.
"""
def __init__(
self,
provider_factory: ProviderFactory,
*,
tool_source: Optional[ToolSourceFactory] = None,
gate_factory: Optional[GateFactory] = None,
runner: Optional[Callable[..., Any]] = None,
security_config: Any = None,
) -> None:
self._provider_factory = provider_factory
self._tool_source = tool_source
self._gate_factory = gate_factory
self._runner = runner
self._security_config = security_config
# -- main entry point -------------------------------------------------- #
def run_turn(
self,
request: ConversationExecutionRequest,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Execute one turn and return everything it produced.
Never raises: a provider or tool failure becomes an :class:`ErrorEvent`
plus ``TurnResult.error``. Callers run this on a worker thread and have
no good way to handle an exception crossing that boundary - today an
escaped error kills the worker and the UI just stops updating, with no
message shown.
Exactly one :class:`TurnCompletedEvent` is always emitted last, whether
the turn succeeded, failed or was cancelled. That is the end-of-turn
signal the legacy engine never had.
"""
return self.execute_turn(self.begin_turn(request), on_event=on_event, cancel=cancel)
def begin_turn(self, request: ConversationExecutionRequest) -> TurnResult:
"""Create the (still empty) result a turn will fill in.
Exposed separately from :meth:`run_turn` because some callers need the
LIVE message list while the turn is running, not only afterwards: the
scheduler re-saves the conversation to History after every assistant
message so a long unattended run shows live progress when reopened.
Handing them ``result.messages`` - the very list the engine appends to -
is what makes that possible without leaking the engine into the caller.
"""
return TurnResult(request=request, messages=request.message_list())
def execute_turn(
self,
result: TurnResult,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Run a turn previously created by :meth:`begin_turn`. See
:meth:`run_turn` for the error/cancellation contract."""
request = result.request
emit = self._make_emitter(result, on_event)
cancel = cancel or (lambda: False)
try:
self._execute(request, result, emit, cancel)
except Exception as exc: # noqa: BLE001 - see docstring
result.error = str(exc) or exc.__class__.__name__
result.exception = exc
logger.exception("turn %s failed", request.turn_id)
emit(ErrorEvent(message=result.error,
recoverable=self._is_recoverable(exc)))
result.cancelled = bool(cancel())
result.final_text = collect_text(result.events) or self._last_assistant_text(result.messages)
emit(TurnCompletedEvent(content=result.final_text, cancelled=result.cancelled))
return result
# -- internals --------------------------------------------------------- #
def _execute(self, request: ConversationExecutionRequest, result: TurnResult,
emit: Callable[[AgentEvent], None], cancel: CancelFn) -> None:
"""Assemble the engine call from the request snapshot and run it."""
provider = self._provider_factory(request.provider, request.model)
extra_tools, extra_executor = self._resolve_tools()
gate = self._resolve_gate(request)
# The engine speaks untyped dicts; bridge them into typed events at this
# single point rather than at every consumer.
def legacy_emit(payload: Dict[str, Any]) -> None:
event = event_from_dict(payload)
if event is not None:
emit(event)
run = self._resolve_runner()
run(
provider,
result.messages, # mutated in place by the engine, as before
self._output_dir(request),
legacy_emit,
cancel,
title=request.title,
extra_tools=extra_tools,
extra_executor=extra_executor,
project_context=request.project_context,
security_config=self._security_config,
gate=gate,
allowed_tools=list(request.allowed_tools) if request.allowed_tools is not None else None,
max_steps=request.max_steps,
run_to_completion=request.run_to_completion,
completion_max_steps=request.completion_max_steps,
enforce_rules=request.enforce_rules,
**self._role_kwargs(request),
)
@staticmethod
def _make_emitter(result: TurnResult,
on_event: Optional[EventCallback]) -> Callable[[AgentEvent], None]:
"""Record every event on the result AND forward it to the caller.
Recording is unconditional so a headless caller (the scheduler) can read
the full event list afterwards without having to supply a callback just
to collect it - which is exactly what task_executors does today with an
ad-hoc list.
"""
def emit(event: AgentEvent) -> None:
result.events.append(event)
if on_event is None:
return
try:
on_event(event)
except Exception: # noqa: BLE001
# A consumer that throws (a closing widget, say) must not abort
# the turn that is feeding it.
logger.debug("event consumer raised for %s", event.type, exc_info=True)
return emit
def _resolve_runner(self) -> Callable[..., Any]:
"""The turn engine, imported lazily on first use."""
if self._runner is None:
from cowork_local.core.chat_agent import run_cowork
self._runner = run_cowork
return self._runner
def _resolve_tools(self) -> Tuple[Any, Any]:
"""MCP/connector tools for this turn, or ``(None, None)``.
A failure here degrades to "no external tools" rather than failing the
turn: an MCP server that will not start must not stop the user from
chatting, which is the behaviour the chat panel already relies on.
"""
if self._tool_source is None:
return None, None
try:
return self._tool_source()
except Exception: # noqa: BLE001
logger.warning("tool source unavailable - running without external tools",
exc_info=True)
return None, None
def _resolve_gate(self, request: ConversationExecutionRequest) -> Any:
"""The permission gate, when this turn asked to confirm commands."""
if not request.confirm_commands or self._gate_factory is None:
return None
return self._gate_factory(request)
@staticmethod
def _output_dir(request: ConversationExecutionRequest) -> Path:
"""The turn's output folder as a Path.
The request holds it as a string to stay serialisable; converting at the
single point of use keeps that decision from leaking into every caller.
"""
return Path(request.output_dir) if request.output_dir else Path.cwd()
@staticmethod
def _role_kwargs(request: ConversationExecutionRequest) -> Dict[str, Any]:
"""``agent_role`` only when the request set one.
Omitted otherwise so the engine applies its own default (the interactive
Cowork role) instead of being handed an empty string, which would land
in the audit log as an unattributed tool call.
"""
return {"agent_role": request.agent_role} if request.agent_role else {}
@staticmethod
def _last_assistant_text(messages: List[Dict[str, Any]]) -> str:
"""Fallback answer text when no text events were seen.
A turn whose whole answer arrived in one non-streamed message still has
to report a final answer - the scheduler writes it into output.md, and
an empty string there reads as "(no output)".
"""
for message in reversed(messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@staticmethod
def _is_recoverable(exc: Exception) -> bool:
"""Whether the user can act on this failure themselves.
"Model not found" is the motivating case: the chat panel restores the
typed message into the composer so the user can switch model and resend
instead of retyping it (see providers/base.py::MODEL_NOT_FOUND_HINT).
"""
try:
from cowork_local.providers.base import is_model_not_found_error
return bool(is_model_not_found_error(str(exc)))
except Exception: # noqa: BLE001
return False
__all__ = ["ConversationApplicationService", "TurnResult"]
@@ -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"]
+12
View File
@@ -0,0 +1,12 @@
"""Model routing use case: pick the best-fit model for one turn (EPIC R03)."""
from .routing_application_service import (
RoutingApplicationService,
RoutingDecision,
RoutingMode,
is_valid_mode,
normalize_mode,
)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"normalize_mode", "is_valid_mode"]
@@ -0,0 +1,353 @@
"""RoutingApplicationService - one routing flow for every surface (R03-T03).
Before this service, the same routing algorithm existed three times:
* ``ui/chat_panel.py::_apply_routing`` (Cowork chat)
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E studio)
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
The three copies had already drifted - each one resolves the "current model"
differently and each one has its own private notion of what to do when the user
declines - and every one of them lives inside a Qt widget, so none of the logic
could be tested without building a window.
This module is the single implementation. It is pure Python: no Qt import, no
config access, no network. The presentation layer supplies a confirm callback
and renders the notice; everything else happens here.
Modes (:class:`RoutingMode`)
----------------------------
* ``OFF`` - never switch. The user's pinned model always wins.
* ``AUTO`` - switch silently when the best candidate clears the gain threshold.
* ``MANUAL`` - propose the switch and switch only if the confirm callback approves.
* ``FALLBACK`` - never switch pre-emptively; switch only AFTER the current model
fails, to the next-best candidate. This is the mode a user wants when they
trust their own model choice but still want the turn to survive an outage.
Migration note (ADR-001 section 4): the scoring/ranking engine is NOT rewritten.
This service depends on the small :class:`RoutingPort` interface, and production
wires the existing, already-tested ``core.routing.service.RoutingService`` into
it. Tests wire a fake.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, List, Optional, Protocol, Sequence, Tuple
class RoutingMode(str, Enum):
"""Per-surface routing behaviour.
The first three values match ``core.routing.models.SwitchMode`` string for
string, so a mode read from the existing config round-trips unchanged.
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any) -> "RoutingMode":
"""Best-effort parse of a config value.
Unknown or empty values become ``OFF``: routing is an optimisation, and
the safe reading of a corrupt setting is "leave the user's model alone"
rather than "silently move their work to another model".
"""
try:
return cls(str(raw or "off").strip().lower())
except ValueError:
return cls.OFF
@dataclass(frozen=True)
class RoutingDecision:
"""The outcome of routing one turn - an immutable instruction for the caller.
``provider``/``model`` are ALWAYS filled with what the turn should actually
run on, switched or not, so a call site never has to re-derive the fallback
itself (the bug that made the three UI copies diverge).
"""
mode: RoutingMode
provider: str
model: str
switched: bool = False
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
declined: bool = False # Manual mode: a switch was offered and refused
# What the turn would have run on without routing. Carried so the Manual
# confirm dialog can show "from X to Y" without re-deriving the current
# model itself - re-deriving it differently per screen is exactly how the
# three legacy copies drifted apart.
previous_provider: str = ""
previous_model: str = ""
@property
def should_notify(self) -> bool:
"""True when the UI should show the "switched model" notice - i.e. only
when a switch really happened."""
return self.switched
def target(self) -> Tuple[str, str]:
"""``(provider, model)`` to run this turn on."""
return self.provider, self.model
@property
def from_model(self) -> str:
"""Candidate key (``provider/model``) of the model being switched away
from, or "" when nothing was selected yet.
Named to match ``core.routing.models.SwitchDecision`` so the existing
Manual-mode dialog (``ui/routing_toggle.py::confirm_switch``) accepts
this object unchanged - the dialog moves to the new shape in EPIC R08.
"""
if not self.previous_model:
return ""
return f"{self.previous_provider}/{self.previous_model}"
@property
def to_model(self) -> str:
"""Candidate key (``provider/model``) of the model to run on. See
:attr:`from_model` for why the name matches the legacy decision."""
return f"{self.provider}/{self.model}" if self.model else ""
def is_valid_mode(raw: Any) -> bool:
"""True when ``raw`` names a mode the routing service understands.
Distinct from :func:`normalize_mode` because callers need to tell "the user
chose off" apart from "this stored value is unrecognised" - the per-workspace
lookup falls back to the global setting only in the second case.
"""
try:
RoutingMode(str(raw or "").strip().lower())
except ValueError:
return False
return True
def normalize_mode(raw: Any) -> str:
"""Canonical mode string for persistence, or ``"off"`` when unrecognised.
Exists so the mode vocabulary is defined exactly once. It used to be
hard-coded as a ``("off", "auto", "manual")`` tuple in four separate places
(config.py twice, state.py twice); adding FALLBACK meant finding all four,
and missing one silently downgraded the user's choice back to "off".
"""
return RoutingMode.parse(raw).value
class RoutingPort(Protocol):
"""The slice of the routing engine this service needs.
Declared as a Protocol so the application layer states its requirement
without importing the implementation - which is what lets the whole service
be tested against a 20-line fake, and lets ``core.routing`` be replaced later
without touching this file.
"""
def route(self, surface: str, prompt: str, current_provider: str, current_model: str,
*, mode_override: Optional[str] = None,
required_capabilities: Optional[List[str]] = None,
task_type: Optional[Any] = None) -> Any:
"""Return a route result exposing ``should_switch``, ``target()``,
``task_type`` and ``decision``."""
# Presentation supplies this to ask the human. Receives the proposal so the
# dialog can explain it; returns True to approve. Manual mode only.
ConfirmFn = Callable[[RoutingDecision], bool]
class RoutingApplicationService:
"""Decides which provider/model one turn runs on.
Args:
router: the scoring engine (see :class:`RoutingPort`).
mode_reader: ``surface -> mode string``; production passes the per-workspace
lookup ``AppContext.project_routing_mode``. Injected rather than read
from config here so this layer stays free of config plumbing that
EPIC R02 is rewriting in parallel.
"""
def __init__(self, router: RoutingPort,
mode_reader: Optional[Callable[[str], str]] = None) -> None:
self._router = router
self._mode_reader = mode_reader
# -- main entry point -------------------------------------------------- #
def route_turn(
self,
surface: str,
prompt: str,
current_provider: str,
current_model: str,
*,
mode: Optional[str] = None,
confirm: Optional[ConfirmFn] = None,
required_capabilities: Optional[Sequence[str]] = None,
task_type: Optional[Any] = None,
) -> RoutingDecision:
"""Decide what to run this turn on. Never raises.
A routing failure must never block a message: any unexpected error
degrades to "keep the current model", which is exactly what all three
legacy copies did with a bare ``except`` - made explicit and testable here.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
keep = self._keep(resolved_mode, current_provider, current_model,
reason="routing off - keeping current model")
# An empty prompt carries no signal to classify, so routing cannot make a
# meaningful choice; the same guard exists in all three legacy copies.
if resolved_mode is RoutingMode.OFF or not (prompt or "").strip():
return keep
# FALLBACK never switches up front - it only reacts to a failure, which
# the caller reports through fallback_after_failure().
if resolved_mode is RoutingMode.FALLBACK:
return self._keep(resolved_mode, current_provider, current_model,
reason="fallback mode - switching only after a failure")
try:
result = self._router.route(
surface, prompt, current_provider, current_model,
mode_override=resolved_mode.value,
required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - routing must never break a turn
return self._keep(resolved_mode, current_provider, current_model,
reason="routing engine failed - keeping current model")
proposal = self._to_decision(result, resolved_mode, current_provider, current_model)
if not proposal.switched:
return proposal
# Manual mode: the proposal only becomes a switch once a human approves.
if resolved_mode is RoutingMode.MANUAL:
if confirm is None or not self._ask(confirm, proposal):
return self._keep(resolved_mode, current_provider, current_model,
reason="switch declined - keeping current model",
task_type=proposal.task_type, declined=True)
return proposal
# -- failure recovery -------------------------------------------------- #
def fallback_after_failure(
self,
surface: str,
prompt: str,
failed_provider: str,
failed_model: str,
*,
mode: Optional[str] = None,
required_capabilities: Optional[Sequence[str]] = None,
task_type: Optional[Any] = None,
) -> Optional[RoutingDecision]:
"""Pick a replacement after ``failed_provider/failed_model`` failed.
Returns None when there is nothing to fall back to, so the caller can
surface the original error instead of retrying forever. Available in
AUTO and FALLBACK; OFF and MANUAL keep the user's model on failure too,
because silently moving work to another model is exactly what those two
modes exist to prevent.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
if resolved_mode not in (RoutingMode.AUTO, RoutingMode.FALLBACK):
return None
try:
# Asked in AUTO so the engine ranks candidates rather than short-
# circuiting on FALLBACK's "never switch up front" rule; the failed
# model is passed as current so any positive gain beats it.
result = self._router.route(
surface, prompt, failed_provider, failed_model,
mode_override=RoutingMode.AUTO.value,
required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - a broken router must not mask the real error
return None
decision = self._to_decision(result, resolved_mode, failed_provider, failed_model)
# A "switch" back to the model that just failed would retry the outage.
if not decision.switched or (decision.provider, decision.model) == (failed_provider, failed_model):
return None
return RoutingDecision(
mode=resolved_mode, provider=decision.provider, model=decision.model,
switched=True, task_type=decision.task_type, score_gain=decision.score_gain,
reason=f"{failed_provider}/{failed_model} failed - falling back to "
f"{decision.provider}/{decision.model}",
previous_provider=failed_provider, previous_model=failed_model,
)
# -- internals --------------------------------------------------------- #
def _read_mode(self, surface: str) -> str:
"""Per-surface mode from the injected reader ('off' when none supplied)."""
if self._mode_reader is None:
return RoutingMode.OFF.value
try:
return self._mode_reader(surface) or RoutingMode.OFF.value
except Exception: # noqa: BLE001 - a config read must not break a turn
return RoutingMode.OFF.value
@staticmethod
def _keep(mode: RoutingMode, provider: str, model: str, *, reason: str,
task_type: str = "", declined: bool = False) -> RoutingDecision:
"""A no-switch decision that still names the model to run on."""
return RoutingDecision(mode=mode, provider=provider, model=model, switched=False,
task_type=task_type, reason=reason, declined=declined,
previous_provider=provider, previous_model=model)
@staticmethod
def _ask(confirm: ConfirmFn, proposal: RoutingDecision) -> bool:
"""Run the confirm callback, treating any failure as "declined".
The callback opens a modal dialog in production; if that raises (window
already closing, for instance) the safe answer is to keep the user's own
model rather than to switch without consent.
"""
try:
return bool(confirm(proposal))
except Exception: # noqa: BLE001
return False
@staticmethod
def _to_decision(result: Any, mode: RoutingMode,
current_provider: str, current_model: str) -> RoutingDecision:
"""Translate the engine's route result into a :class:`RoutingDecision`.
Defensive about the result shape on purpose: this is the seam between the
new layer and a legacy module still under refactor, and a missing
attribute must degrade to "keep current model" instead of raising into
the middle of a chat turn.
"""
inner = getattr(result, "decision", None)
task_type = getattr(getattr(result, "task_type", None), "value", "") or ""
gain = float(getattr(inner, "score_gain", 0.0) or 0.0)
reason = str(getattr(inner, "reason", "") or "")
target = None
if getattr(result, "should_switch", False):
getter = getattr(result, "target", None)
target = getter() if callable(getter) else None
if not target:
return RoutingDecision(mode=mode, provider=current_provider, model=current_model,
switched=False, task_type=task_type, score_gain=gain,
reason=reason or "no better model - keeping current",
previous_provider=current_provider,
previous_model=current_model)
provider, model = target
return RoutingDecision(mode=mode, provider=provider or current_provider, model=model,
switched=True, task_type=task_type, score_gain=gain, reason=reason,
previous_provider=current_provider, previous_model=current_model)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"RoutingPort", "normalize_mode", "is_valid_mode"]
+18
View File
@@ -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"]
+6
View File
@@ -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"]
+17
View File
@@ -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",
]
+40
View File
@@ -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"]
+14 -8
View File
@@ -552,19 +552,25 @@ class AppConfig:
return d
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual mode for a chat surface.
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global
``switch_mode``. The value is validated through
``application.model_routing.normalize_mode`` so the accepted vocabulary
is defined in exactly one place (R03-T03) - it used to be a literal
tuple repeated here and in state.py, and adding a mode to one copy but
not the others silently downgraded the user's choice to "off"."""
from .application.model_routing import normalize_mode
A per-surface override ("auto"/"manual"/"off") wins; an empty override
falls back to the global ``switch_mode``."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in ("off", "auto", "manual") else "off"
return normalize_mode(override or routing.get("switch_mode", "off"))
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's Off/Auto/Manual toggle selection."""
mode = mode if mode in ("off", "auto", "manual") else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
"""Persist a chat surface's routing toggle selection."""
from .application.model_routing import normalize_mode
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
self.save()
@property
+46 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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]:
+7
View File
@@ -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
View File
@@ -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
+38 -6
View File
@@ -248,9 +248,34 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
messages = [{"role": "user", "content": prompt}]
# One immutable snapshot of this run, then the shared turn service (R04-T05).
# The Schedule Task path used to assemble the run_cowork call itself, in
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
# so a fix to one path silently missed the other. Both now go through
# ConversationApplicationService.
from ..application.conversations import ConversationApplicationService
from ..domain.agents import ConversationExecutionRequest
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
project_context = projects.project_context_text(project)
conversation_service = ConversationApplicationService(
# The provider was already resolved above (admin agent / per-task
# override / machine default), so the factory just hands it back.
lambda _provider_id, _model: provider,
security_config=ctx.config,
)
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
prompt, [{"role": "user", "content": prompt}],
output_dir=str(out_dir), session_id=session_id, surface="task",
title=title, project_id=project_id, project_context=project_context,
# Tags every tool call in the audit log as a scheduled task rather than
# as the interactive Cowork tab.
agent_role=agent_roles.TASK,
))
# The LIVE list the engine appends to - History is re-saved from it after
# every assistant message so a long run shows progress when reopened.
messages = turn.messages
_save_history_session(ctx, task_type, title, messages, session_id, project_id)
# Tell the scheduler the session now genuinely EXISTS on disk — it
# refreshes History on this, not on the earlier "task_started" signal
@@ -269,14 +294,21 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
elif ev.get("type") == "plan_set":
last_plan_steps[:] = ev.get("steps") or []
project_context = projects.project_context_text(project)
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# Typed events are rendered back into the legacy dict shape this
# module's autosave/plan tracking already consumes; it moves to
# AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
result = conversation_service.execute_turn(
turn,
on_event=lambda event: emit_and_autosave(event.to_dict()),
cancel=watched_cancel,
)
# This module's callers handle a failed run through an exception
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
# error rather than reporting a silently empty answer.
result.raise_if_failed()
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)
+20 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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,156 @@
# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
* **Status**: Accepted
* **Date**: 2026-08-21
* **EPIC / Task**: R01-T01
* **Owner**: 🔵 Team Duy (Tech Lead)
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
---
## 1. Context (Bối cảnh)
`cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
| Vấn đề | Bằng chứng cụ thể trong repo |
| :--- | :--- |
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
sẽ hội tụ về đúng cấu trúc rối như cũ.
## 2. Decision (Quyết định)
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
```text
┌─────────────────────────────────────────────────────────────┐
│ presentation/ PySide6 widgets, Qt signals/slots │
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ application/ Pure Python orchestration │
│ (conversations, Điều phối use-case, không biết Qt │
│ model_routing…) và không biết HTTP/đĩa cụ thể │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ domain/ Pure Python entities & events │
│ (agents, models…) Frozen dataclass, enum, quy tắc │
│ nghiệp vụ thuần. KHÔNG import gì │
│ từ 3 tầng còn lại. │
└───────────────────────────▲─────────────────────────────────┘
│ implement interface của domain
┌───────────────────────────┴─────────────────────────────────┐
│ infrastructure/ Adapters: network, keyring, đĩa, │
│ (providers, telemetry…) process, Qt-free I/O │
└─────────────────────────────────────────────────────────────┘
```
### 2.1 Quy tắc bất biến (Invariants)
| # | Quy tắc | Được kiểm bởi |
| :--- | :--- | :--- |
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
### 2.2 Chiều phụ thuộc được phép
| Từ tầng | Được import | Bị cấm |
| :--- | :--- | :--- |
| `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
| `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
| `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
`application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
(`domain/agents/agent_event.py`, R04-T02):
```python
# application layer — pure Python, không biết Qt tồn tại
service.run_turn(request, on_event=my_callback)
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
def my_callback(event: AgentEvent) -> None:
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
```
Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
bao giờ gọi trực tiếp từ worker thread.
## 3. Vị trí sở hữu theo team
| Tầng / thư mục | Team | EPIC |
| :--- | :--- | :--- |
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
## 4. Chiến lược di trú (Strangler Fig, không big-bang)
Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
scorer/selector đã có test).
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
team khác refactor xong.
## 5. Consequences (Hệ quả)
### Tích cực
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
### Tiêu cực / chi phí phải chấp nhận
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
## 6. Alternatives considered (Phương án đã cân nhắc)
| Phương án | Lý do loại |
| :--- | :--- |
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
## 7. Enforcement (Thực thi)
```bash
python scripts/check_imports.py # I1, I2, I3 — quét AST
python scripts/check_loc.py # I4 — giới hạn 400 dòng
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
```
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
## 8. Tài liệu liên quan
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
+85
View File
@@ -0,0 +1,85 @@
# Dormant / Dead Code Inventory (R01-T05)
* **Task**: R01-T05 — Phân loại và cô lập mã nguồn cũ
* **Owner**: 🔵 Team Duy
* **Ngày quét**: 2026-08-21
* **Phạm vi quét**: toàn bộ `*.py` production (loại trừ `tests/`, `assets/`, `docs/`, `.git/`)
---
## 1. Mục đích
Trước khi 3 team refactor song song, cần biết **file nào thật sự đang chạy**. Refactor
một module đã chết là lãng phí; xoá nhầm một module chỉ được gọi động là gây sự cố
runtime. Tài liệu này phân loại từng ứng viên, kèm **bằng chứng** và **hành động đề xuất**.
## 2. Phương pháp
Quét AST toàn repo, dựng đồ thị import, tìm module **không có module nào khác import**.
Kết quả thô: **43 module**. Sau đó xác minh thủ công từng ứng viên, vì phân tích tĩnh
không thấy 3 kiểu tham chiếu:
| Kiểu tham chiếu ẩn | Ví dụ thật trong repo |
| :--- | :--- |
| Chạy như subprocess | `state.py:285` gọi `python -m cowork_local.mcp_servers.ms365_server` |
| Entry point của gói | `__main__.py` (chạy bằng `python -m cowork_local`) |
| Script chạy tay | `tools/check_*.py`, `scripts/*.py` |
> ⚠️ **Kết luận quan trọng**: 43 module "không ai import" **KHÔNG** đồng nghĩa 43 module chết.
> Sau xác minh, chỉ còn **6 hạng mục (~1.887 dòng)** là dormant thật.
## 3. Phân loại kết quả
### 🟥 A. DORMANT THẬT — không có đường nào chạy tới (ứng viên xoá)
| Module | LOC | Bằng chứng | Rủi ro khi xoá | Hành động |
| :--- | ---: | :--- | :--- | :--- |
| `ui/accounts_tab.py` | 700 | Chỉ xuất hiện trong comment của `i18n.py:92`; không widget nào khởi tạo `AccountsTab` | Thấp — panel Monitoring → Accounts hiện không có đường vào | Cô lập, chờ xác nhận PO rồi xoá |
| `ui/flow_dialog.py` | 596 | Chỉ được nhắc trong docstring `ui/agent_manager_tab.py:4` và comment `i18n.py:2124` | Trung bình — Flow Manager có thể là tính năng tạm ẩn | **Hỏi PO trước**, chưa xoá |
| `security/` (cả package) | 296 | `prompt_validator`, `action_validator`, `attachment_validator`, `audit_logger`, `command_risk_classifier` — không file nào ngoài package tự import. Chức năng **trùng** `core/agent_security.py` + `core/security_rules.py` (đang chạy thật) | Trung bình — dễ nhầm đây là lớp bảo mật đang hoạt động | ⚠️ Ưu tiên cao: xoá hoặc hợp nhất trong **R09 (Team Nam)** |
| `core/codebase_memory_ui.py` | 123 | Không nơi nào import; `core/codebase_memory.py` (bản không-UI) mới là bản đang dùng | Thấp | Xoá |
| `core/graph_server.py` | 115 | Docstring nói phục vụ build không có QtWebEngine, nhưng **không có call site nào**; `ui/structure_graph_view.py` không gọi | Trung bình — có thể là fallback cho bản .exe chưa nối dây | Xác minh với bản đóng gói PyInstaller trước khi xoá |
| `ui/mcp_servers_dialog.py` | 57 | Không import; MCP settings hiện nằm trong `ui/settings_dialog.py` | Thấp | Xoá |
**Tổng: ~1.887 dòng (≈ 3,4% codebase).**
### 🟨 B. KHÔNG CHẾT — chạy qua đường ẩn (giữ nguyên)
| Module | Vì sao phân tích tĩnh báo nhầm |
| :--- | :--- |
| `__main__.py` | Entry point `python -m cowork_local` |
| `mcp_servers/ms365_server.py` | Chạy như tiến trình con — `state.py:285` |
| `core/routing/__init__.py` | Được import qua đường dẫn con (`from .routing.service import RoutingService`), heuristic theo tên lá không thấy |
| `tools/check_*.py` (34 file, 6.608 dòng) | Bộ smoke-test UI chạy tay: `python tools/check_nav.py`. Là **dev tooling**, không phải code chết |
| `scripts/bootstrap_gitea_repo.py`, `scripts/check_imports.py` | Script CLI chạy tay / chạy trong CI |
### 🟩 C. CODE SỐNG NHƯNG "ĐÓNG BĂNG" — đụng vào phải cẩn thận
| Module | LOC | Ghi chú cho người refactor |
| :--- | ---: | :--- |
| `core/chat_agent.py::run_cowork` | 580 | Đang có **characterization test** (`tests/characterization/test_run_cowork.py`, R01-T04). Mọi thay đổi hành vi phải làm cùng lúc với cập nhật snapshot |
| `providers/base.py` | 401 | Là contract chung của mọi provider; đổi chữ ký = vỡ cả 3 team. Đã có contract test (R03-T01) |
| `core/routing/*` | 2.263 | Đã có 79 test đang xanh. R03 **bọc** chứ không viết lại: `application/model_routing/` gọi xuống đây |
## 4. Quy tắc xử lý (bắt buộc)
1. **Không xoá trong cùng PR với refactor.** Xoá code chết là một commit riêng, để `git revert` được độc lập khi có sự cố.
2. **Cô lập trước, xoá sau.** Đánh dấu module bằng docstring cảnh báo, chạy 1 vòng release; không ai báo lỗi mới xoá.
3. **Hạng mục 🟥 A cần một người xác nhận** (PO hoặc chủ tính năng) trước khi xoá — trừ khi rõ ràng là bản trùng lặp (`codebase_memory_ui`, `mcp_servers_dialog`).
4. **Không refactor code trong nhóm 🟥 A.** Nếu một file trong danh sách này >400 dòng, nó **không** tính vào CASAN Check 2 — vì đường đi đúng là xoá, không phải tách nhỏ.
## 5. Việc cần bàn giao
| Hạng mục | Team nhận | EPIC |
| :--- | :--- | :--- |
| `security/` trùng lặp với `core/agent_security.py` | 🟣 Nam | R09 |
| `ui/accounts_tab.py`, `ui/flow_dialog.py`, `ui/mcp_servers_dialog.py` | 🟣 Nam (sở hữu `presentation/shell/`, `settings/`) | R08 |
| `core/graph_server.py`, `core/codebase_memory_ui.py` | 🟢 Hoa (sở hữu `presentation/graph/`) | R06 |
## 6. Cách chạy lại lần quét này
```bash
python scripts/check_imports.py # ranh giới kiến trúc (R01-T03)
# Bản quét đồ thị import dùng cho tài liệu này sẽ được đóng gói thành
# scripts/find_dormant.py trong R10-T02 (Testing & Governance tooling).
```
+233
View File
@@ -0,0 +1,233 @@
# BÁO CÁO KẾT QUẢ — TEAM DUY: EPIC R01, R03, R04
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Team**: 🔵 Team Duy — Core AI, Routing, Turn Runtime & Testing (Tech Lead)
* **Nhánh**: `feature/deltateam/refactor-plan`
* **Thời gian thực hiện**: 21/08/2026, 09:56 ➔ 10:56
* **Ngày báo cáo**: 21/08/2026
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `DeltaTeam_prompt.md`
---
## 1. Tóm tắt điều hành
Hoàn tất **16/16 task** của 3 EPIC được giao trong đợt này: **R01** (nền tảng kiến trúc & lưới an toàn), **R03** (hợp nhất provider & routing), **R04** (vòng đời turn hội thoại). Toàn bộ đã commit và push lên nhánh.
| Chỉ số | Kết quả |
| :--- | :--- |
| Task hoàn thành | **16/16** (R01: 5, R03: 6, R04: 5) |
| Commit | 5 |
| File thay đổi | 48 (37 file mới, 11 file sửa) |
| Dòng code | +5.843 / −225 |
| Test | **243 pass** / 44s |
| Test suite nhanh (unit + contract + characterization + routing) | **218 pass / 1,22s** |
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
| File production > 400 dòng | **0** |
**3 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5) — trong đó 1 lỗi deadlock sẽ làm treo ứng dụng ngay ở tin nhắn đầu tiên.
---
## 2. Kết quả theo từng EPIC
### 🔹 EPIC R01 — Architecture Foundation & Characterization (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R01-T01 | `docs/architecture/ADR-001-layered-architecture.md` | Định nghĩa 4 tầng, chiều phụ thuộc, 6 quy tắc bất biến I1–I6, chiến lược di trú Strangler Fig |
| R01-T02 | `tests/fakes/fake_provider.py`, `fake_tool_executor.py` | Test double chạy offline, kịch bản hoá, ghi lại mọi lời gọi |
| R01-T03 | `scripts/check_imports.py` (239 dòng) | Quét AST, bắt cả import tương đối (`from ...ui import x`) và import trong thân hàm |
| R01-T04 | `tests/characterization/test_run_cowork.py` | **13 test** chụp snapshot hành vi hiện tại của `run_cowork` trước khi R04 đụng vào |
| R01-T05 | `docs/architecture/dormant-code.md` | Quét đồ thị import: 43 module "không ai import" ➔ xác minh còn **6 hạng mục chết thật (~1.887 dòng)** |
**Điểm đáng chú ý ở R01-T03**: dùng AST thay vì `grep` là bắt buộc — trong repo có nhiều docstring nhắc tên `PySide6` một cách hợp lệ, `grep` sẽ báo nhầm và đội sẽ học cách tắt cổng kiểm duyệt.
**Điểm đáng chú ý ở R01-T05**: 43 module không có importer **không** đồng nghĩa 43 module chết. Sau xác minh thủ công: `__main__.py` là entry point, `mcp_servers/ms365_server.py` chạy bằng subprocess (`state.py:285`), 34 file `tools/check_*.py` là dev tooling chạy tay. Chỉ 6 hạng mục là dormant thật.
### 🔹 EPIC R03 — Model Providers & Routing (6/6)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R03-T01 | `tests/contracts/test_providers.py` | **29 contract test**; chạy được cả 2 adapter thật mà **không cần mạng** nhờ thay `Provider._request` bằng SSE đóng hộp |
| R03-T02 | `domain/models/provider_descriptor.py`, `infrastructure/providers/provider_registry.py` | Gom 3 nơi khai báo provider về 1 chỗ |
| R03-T03 | `application/model_routing/routing_application_service.py` | Pure Python, 4 chế độ: Off / Auto / Manual / **Fallback (mới)** |
| R03-T04, T05 | `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Gỡ 3 bản sao logic routing |
| R03-T06 | `infrastructure/telemetry/usage_sink.py` | Tách ghi nhận token usage khỏi provider |
**Vấn đề gốc đã giải quyết** — cùng một thuật toán routing tồn tại **3 bản gần giống nhau**:
```
ui/chat_panel.py::_apply_routing (~45 dòng)
ui/co4e_tab.py::_apply_co4e_routing (~38 dòng)
ui/folder_tab.py::_ai_apply_routing (~42 dòng)
```
Cả 3 đều nằm trong widget Qt ➔ **không thể test nếu không dựng cửa sổ**, và đã bắt đầu lệch nhau (mỗi bản xác định "model hiện tại" một kiểu). Nay cả 3 chỉ còn gọi `ctx.routing_application().route_turn(...)` + một callback xác nhận.
**Chế độ Fallback (mới)**: giữ nguyên model người dùng chọn, **chỉ đổi sau khi model đó lỗi**. Đây là chế độ người dùng cần khi họ tin lựa chọn của mình nhưng vẫn muốn lượt chat sống sót qua sự cố nhà cung cấp.
**Bộ từ vựng mode**: trước đây tuple `("off", "auto", "manual")` bị lặp ở **4 chỗ** (`config.py` × 2, `state.py` × 2). Thêm một mode mà quên một chỗ sẽ **âm thầm hạ lựa chọn của người dùng về "off"**. Nay tập trung vào `normalize_mode()` / `is_valid_mode()`.
### 🔹 EPIC R04 — Agent Runtime & Conversation Service (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R04-T01 | `domain/agents/conversation_execution_request.py` | Frozen dataclass, chụp toàn bộ input của 1 turn tại thời điểm submit |
| R04-T02 | `domain/agents/agent_event.py` (370 dòng) | **13 event có kiểu** thay cho dict không kiểu, kèm cầu nối 2 chiều |
| R04-T03 | `application/conversations/conversation_application_service.py` | Điều phối vòng đời turn, không import Qt |
| R04-T04 | `ui/cowork_tab.py::build_job` | Chuyển sang snapshot + service |
| R04-T05 | `core/task_executors.py::_run_agent` | Chuyển sang **cùng** service (trước đây là bản lắp ráp thứ hai, hơi khác) |
**Vấn đề gốc đã giải quyết** — closure trong `build_job` đọc state của widget **từ trong worker thread**:
```python
def job(worker):
provider = self.build_provider() # đọc combo box
proj_ctx = project_context_text(load_project(project_id))
```
Người dùng có thể đổi model, đổi workspace, sửa chỉ dẫn project **trong lúc turn đang chạy**. Turn khi đó chạy trên hỗn hợp state cũ + mới, và hỗn hợp nào phụ thuộc vào thời điểm luồng — đúng loại bug tái hiện mỗi tuần một lần và không bao giờ tái hiện trong test.
**`TurnCompletedEvent`** là tín hiệu kết thúc turn mà engine cũ **hoàn toàn không có**: hiện tại mọi consumer suy ra "xong" từ việc worker thread kết thúc, nên **turn bị huỷ và turn thất bại trông giống hệt nhau** với giao diện.
---
## 3. Kiến trúc sau refactor
```text
presentation/ ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py, ui/cowork_tab.py
│ (chỉ dựng UI, mở dialog xác nhận, render thông báo)
▼
application/ model_routing/routing_application_service.py ← 4 mode routing
conversations/conversation_application_service.py ← vòng đời turn
│ (100% pure Python — cổng kiểm duyệt tự động chặn import Qt)
▼
domain/ agents/conversation_execution_request.py ← snapshot bất biến
agents/agent_event.py ← 13 event có kiểu
models/provider_descriptor.py ← catalog provider
▲
infrastructure/ providers/provider_registry.py telemetry/usage_sink.py
```
**Nguyên tắc di trú (ADR-001 mục 4)**: **không viết lại engine**. `core/chat_agent.py::run_cowork` và `core/routing/*` (2.263 dòng, 79 test đang xanh) vẫn là engine bên dưới; tầng application chỉ sở hữu phần trước đây bị trộn vào UI. Nhờ vậy `pytest` luôn xanh giữa các bước và một team có thể merge mà không phải chờ team khác.
---
## 4. Bằng chứng kiểm thử
### Phân bố test
| Suite | Số test | Thời gian | Vai trò |
| :--- | ---: | ---: | :--- |
| `tests/unit/` | 97 | | Logic thuần, không Qt/mạng |
| `tests/contracts/` | 29 | | Mọi provider phải thoả cùng bộ cam kết |
| `tests/characterization/` | 13 | | Chốt hành vi hiện tại của `run_cowork` |
| `tests/routing/` | 79 | | Có sẵn từ trước, vẫn xanh |
| **Cộng 4 suite nhanh** | **218** | **1,22s** | ✅ đạt CASAN "A — unit < 1s" |
| `tests/integration/` | 25 | 42s | Widget Qt thật (offscreen) + provider kịch bản hoá |
| **Tổng** | **243** | **44s** | |
### Đối chiếu Definition of Done (7 tiêu chí, `DeltaTeam_prompt.md`)
| # | Tiêu chí | Kết quả |
| :--- | :--- | :--- |
| 1 | Mọi file < 400 dòng | ✅ Lớn nhất: `agent_event.py` 370 dòng |
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
| 3 | Comment tiếng Anh ở mọi khối sửa/mới | ✅ Docstring + giải thích **lý do**, không chỉ mô tả code |
| 4 | Có unit/contract test, pass 100% < 1s | ✅ 218 test / 1,22s |
| 5 | Không hồi quy | ✅ 79 test routing có sẵn vẫn xanh |
| 6 | Ghi Start/End vào Checklist | ✅ 16 task đã tick kèm mốc thời gian |
| 7 | Cổng CASAN | ⚠️ `run_quality_gate.py` thuộc **R10-T02**, chưa viết. Check 3 đã có và PASS |
### Ba đường code đã sửa nhưng ban đầu chưa được thực thi
Sau khi hoàn tất 16 task, rà soát lại phát hiện 3 đường code đã bị sửa nhưng **không test nào chạy qua**. Đã bổ sung **18 test**:
| Đường code | Rủi ro nếu bỏ qua | Test bổ sung |
| :--- | :--- | ---: |
| `task_executors._run_agent` | Autosave History có thể đóng băng ở tin nhắn đầu | 7 |
| `_apply_co4e_routing` / `_ai_apply_routing` | Mới chỉ import được, chưa từng gọi hàm | 11 |
| `confirm_switch(decision)` Manual mode | Thiếu field ➔ **nổ bên trong modal**, nơi khó phát hiện nhất | (nằm trong 11 ở trên) |
---
## 5. Ba lỗi thật phát hiện trong quá trình làm
### 🔴 Lỗi 1 — Deadlock khi khởi tạo routing service
`AppContext.routing_application()` giữ `_routing_lock` rồi gọi `routing()`, vốn cũng lấy **chính lock đó**. `threading.Lock` không reentrant ➔ **treo cứng ngay ở tin nhắn đầu tiên**, không có thông báo lỗi.
*Sửa*: tách `_routing_app_lock` riêng, và resolve engine **trước khi** lấy lock.
### 🟠 Lỗi 2 — Event `notice` bị cầu nối nuốt mất
Bản đầu của `agent_event.py` liệt kê 12 loại event nhưng **thiếu `notice`**. Trong khi đó `notice` được phát ra từ 3 nơi trên đường chạy bình thường:
* `core/agent_security.py` — yêu cầu/lệnh bị Agent Security **chặn**
* `core/context_budget.py` — hội thoại vừa bị tự động nén
* Bộ đọc file đính kèm — file không xử lý được, và tiến độ "đang đọc trang X/Y"
Cầu nối bỏ qua event không nhận diện được (đúng thiết kế, để engine có thể thêm event mới) — nên **người dùng sẽ không bao giờ thấy cảnh báo bảo mật**, hoàn toàn im lặng.
*Sửa*: thêm `NoticeEvent`, **và** thêm test quét mã nguồn engine tìm mọi tag `emit({"type": ...})` rồi bắt lỗi nếu có tag nào chưa có event tương ứng — biến sự im lặng thành test đỏ.
### 🟡 Lỗi 3 — Test đang chạy trên checkout khác
`tests/routing/conftest.py` đẩy thư mục cha vào `sys.path`. Vì thư mục checkout tên là `cowork_local_gitea` (không phải `cowork_local`), lệnh `import cowork_local` **ăn nhầm sang `Desktop\cowork_local`** — một bản checkout khác. Suite báo xanh trên mã nguồn **không phải nhánh đang review**.
*Sửa*: `tests/conftest.py` nạp `__init__.py` theo đường dẫn tuyệt đối và đăng ký vào `sys.modules` trước mọi test.
---
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
| Cải thiện | Ảnh hưởng |
| :--- | :--- |
| `ProviderRegistry.build()` đóng dấu `descriptor.id` lên instance | Sửa việc usage của `ollama` / `github_copilot` / `codex` bị ghi nhận nhầm thành `openai_compat` trên Dashboard. **Chưa nối vào production** — xem mục 7. |
| `ProviderRegistry.build()` copy config trước khi ghi | Trước đây một model do routing chọn có thể ghi đè lên default đã lưu của người dùng |
| `UsageTrackerSink` ghi log ở mức debug khi thất bại | Trước là `except: pass` — mất sạch lý do khi Dashboard hỏng |
| `estimate_tokens` được chốt bằng test so với `core.usage_tracker` | Bảo đảm việc tách telemetry **không làm lệch một con số nào** |
---
## 7. Còn nợ & cần quyết định
| # | Nội dung | Người quyết |
| :--- | :--- | :--- |
| 1 | **`ProviderRegistry` chưa nối vào `state.build_provider_for`** (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa lỗi quy kết usage ở mục 6, **nhưng đổi cách gom dữ liệu lịch sử trên Dashboard**. | Team Duy + PO |
| 2 | **Mode `fallback` chưa có trên toggle UI** — config và service đã hỗ trợ đầy đủ; widget `RoutingToggle` thuộc R08. | Team Duy (R08) |
| 3 | **Đã sửa 2 dòng trong `config.py`** (`routing_mode_for`, `set_routing_mode_for`) để dùng chung bộ từ vựng mode. File này Team Nam đang refactor ở R02-T02. | ⚠️ **Cần báo Team Nam** |
| 4 | **Circular import** `core/model_pricing.py` ↔ `core/usage_tracker.py` chưa xử lý (task ngày 28/08). | Team Duy |
| 5 | **2 test đỏ có sẵn từ trước**: `config.py:108` hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py`. Thuộc **EPIC R02 / Team Nam**. | 🟣 Team Nam |
| 6 | `tests/integration/test_routing_surfaces.py` mất 41s do dựng `Co4ETab`/`FolderTab`. Nên gắn marker `slow` khi làm R10. | Team Duy (R10) |
---
## 8. Phạm vi chưa kiểm thử
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
* **Chưa mở ứng dụng bằng tay** — mới chạy widget headless (`QT_QPA_PLATFORM=offscreen`), chưa có ai kiểm tra bằng mắt.
* **Chưa gọi provider thật** — toàn bộ dùng `FakeProvider`, không có lưu lượng mạng.
* **Chưa chạy 34 script `tools/check_*.py`** — các script này tự `sys.path.insert` thư mục cha nên sẽ import nhầm checkout khác (đúng lỗi 3 ở mục 5). Cần sửa chúng ở R10.
---
## 9. Việc kế tiếp của Team Duy
| EPIC | Nội dung | Điều kiện |
| :--- | :--- | :--- |
| **R08** (T01 ➔ T06) | Tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng | Sẵn sàng bắt đầu — `AgentEvent` (R04-T02) chính là kênh dữ liệu 6 widget con sẽ dùng thay vì đọc trực tiếp state của `ChatPanel` |
| **R10** (T01 ➔ T05) | Testing Pyramid, `run_quality_gate.py`, Contributor Recipes, E2E Smoke | Chờ cả 3 team hoàn tất |
---
## 10. Lịch sử commit
| Commit | Nội dung |
| :--- | :--- |
| `bbc09f6` | feat(R01): architecture foundation, offline fakes and characterization net |
| `96bec97` | feat(R03): unify provider catalogue, routing decisions and usage telemetry |
| `a53163e` | feat(R04): immutable turn snapshot, typed agent events, conversation service |
| `15e1d3e` | test(R03/R04): cover the three code paths that were changed but never executed |
| `67b8d2e` | docs(refactor): correct the Team Duy scope block in the checklist |
+238
View File
@@ -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` |
+184 -79
View File
@@ -20,22 +20,127 @@
---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM DUY (cập nhật `2026-08-21 10:55`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 16/16 task của **R01, R03, R04** — đã commit & push lên nhánh `feature/deltateam/refactor-plan`
>
> | EPIC | Task | Trạng thái |
> | :--- | :--- | :--- |
> | **R01** Architecture Foundation | T01 → T05 | ✅ 5/5 |
> | **R03** Providers & Routing | T01 → T06 | ✅ 6/6 |
> | **R04** Agent Runtime & Conversation | T01 → T05 | ✅ 5/5 |
>
> **Kiểm chứng (chạy thật, không phải ước lượng):**
> * `pytest tests/` ➔ **243 pass / 2 fail** trong 44s
> * Suite nhanh (`unit + contracts + characterization + routing`) ➔ **218 pass trong 1,16s** (đạt yêu cầu CASAN "A – Automated Tests < 1s cho unit")
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file production mới **< 400 dòng** (lớn nhất: `routing_application_service.py` 353 dòng)
> * 2 test fail là **lỗi có sẵn từ trước**, thuộc EPIC **R02**: `config.py` vẫn hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py` đỏ
>
> ### 📍 PHẠM VI TEAM DUY & PHẦN CÒN LẠI
> Theo `Feature_Architecture_Proposal.md` (dòng 7) và `DeltaTeam_prompt.md` (dòng 17), Team Duy chủ trì **R01, R03, R04, R08 (phân hệ Chat UI), R10**.
> * ✅ **R01, R03, R04** — xong 16/16 task, đã push.
> * ⬜ **R08 (R08-T01 ➔ R08-T06)** — chưa bắt đầu: tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng.
> * ⬜ **R10** — làm sau cùng, chờ 3 team hoàn tất.
> * **R02 thuộc 🟣 Team Nam** (xem mục EPIC R02 bên dưới) — đây là nguyên nhân 2 test đỏ ở trên, không phải việc của Team Duy.
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md` — kết quả từng EPIC, bằng chứng kiểm thử, 3 lỗi thật đã phát hiện, và phạm vi **chưa** kiểm thử.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. `ProviderRegistry` **chưa nối** vào `state.build_provider_for` (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa luôn lỗi: usage của `ollama`/`github_copilot`/`codex` hiện bị ghi nhận nhầm thành `openai_compat` trên Dashboard — nhưng làm vậy sẽ **đổi cách gom dữ liệu lịch sử**.
> 2. Mode `fallback` đã hỗ trợ ở config + service nhưng **chưa có trên toggle UI** (thuộc R08).
> 3. Đã sửa 2 dòng trong `config.py` (`routing_mode_for` / `set_routing_mode_for`) để dùng chung một bộ từ vựng mode — **cần báo Team Nam** vì file này đang được refactor ở R02.
> 4. Circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` **chưa xử lý** (task ngày 28/08).
> 5. Việc kế tiếp của Team Duy là **R08 phân hệ Chat UI** (6 widget con), rồi **R10** sau cùng.
---
## 📊 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ệ)
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì ADR & Test Doubles) + Phối hợp cả 3 team
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
- [ ] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
*Start: `2026-08-21 09:56` | End: `2026-08-21 10:00`*
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
*Start: `2026-08-21 10:00` | End: `2026-08-21 10:02`*
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
*Start: `2026-08-21 09:58` | End: `2026-08-21 10:05`*
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `2026-08-21 10:02` | End: `2026-08-21 10:04`*
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
*Start: `2026-08-21 10:04` | End: `2026-08-21 10:05`*
---
@@ -62,18 +167,18 @@
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
- [ ] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
*Start: `2026-08-21 10:10` | End: `2026-08-21 10:12`*
- [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
*Start: `2026-08-21 10:06` | End: `2026-08-21 10:10`*
- [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
*Start: `2026-08-21 10:12` | End: `2026-08-21 10:15`*
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
*Start: `2026-08-21 10:17` | End: `2026-08-21 10:20`*
- [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
*Start: `2026-08-21 10:20` | End: `2026-08-21 10:22`*
- [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
*Start: `2026-08-21 10:15` | End: `2026-08-21 10:17`*
---
@@ -81,16 +186,16 @@
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `2026-08-21 10:23` | End: `2026-08-21 10:25`*
- [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
*Start: `2026-08-21 10:22` | End: `2026-08-21 10:23`*
- [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
*Start: `2026-08-21 10:25` | End: `2026-08-21 10:27`*
- [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `2026-08-21 10:27` | End: `2026-08-21 10:31`*
- [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `2026-08-21 10:28` | End: `2026-08-21 10:30`*
---
@@ -98,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`*
---
@@ -115,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`*
---
@@ -132,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)
---
@@ -176,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`*
---
@@ -229,16 +334,16 @@
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 09:56` | `2026-08-21 10:25` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-21 10:06` | `2026-08-21 10:12` | [x] ⚠️ registry chưa nối vào `state.build_provider_for` |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-21 10:12` | `2026-08-21 10:15` | [~] RoutingApplicationService xong; tách widget thuộc R08 |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-21 10:25` | `2026-08-21 10:27` | [~] Service xong; tách widget thuộc R08 |
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-21 10:17` | `2026-08-21 10:22` | [~] 3 bản copy routing đã gỡ; circular import chưa xử lý |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-21 10:35` | `2026-08-21 10:52` | [~] 25 integration test tại `tests/integration/{test_cowork_turn_flow,test_task_executor_flow,test_routing_surfaces}.py` |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `2026-08-21 09:58` | `2026-08-21 10:05` | [x] PASS |
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] chờ 3 team hoàn tất |
---
+12
View File
@@ -0,0 +1,12 @@
"""Domain layer - pure Python entities, value objects and events.
The innermost layer of the 4-tier architecture (see
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
WHAT the application is about - a turn of conversation, a model candidate, an
agent event - and depend on nothing but the standard library.
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
``presentation/`` or the legacy ``core/``/``ui/`` packages. That is what keeps
this layer testable in milliseconds and reusable from a headless scheduler.
"""
+48
View File
@@ -0,0 +1,48 @@
"""Domain entities for one agent turn: the request snapshot and the typed event
stream it produces (EPIC R04)."""
from .agent_event import (
AgentEvent,
AssistantDoneEvent,
ErrorEvent,
HistoryReadyEvent,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
tool_calls,
)
from .conversation_execution_request import (
ConversationExecutionRequest,
new_turn_id,
)
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"event_from_dict",
"collect_text",
"tool_calls",
]
+370
View File
@@ -0,0 +1,370 @@
"""AgentEvent - the typed event stream one agent turn produces (R04-T02).
Today the turn engine talks to its caller through untyped dicts::
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")})
and every consumer re-discovers the vocabulary by reading the producer. There
are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and
``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads
``"result"`` instead of ``"output"`` fails silently, at runtime, only for the
tool path that triggers it.
This module makes the vocabulary explicit. Each event is a frozen dataclass, so:
* the set of possible events is enumerable (see :data:`EVENT_TYPES`);
* a field name typo is an ``AttributeError`` at the point of use, not a silently
missing chat bubble;
* an event can cross a thread boundary safely - it cannot be mutated after the
producer hands it over, which is exactly what the Qt-signal seam needs.
Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict`
adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an
event back into the legacy shape so existing widgets keep working untouched
while the presentation layer migrates screen by screen (EPIC R08).
Pure domain code: stdlib only, no Qt, no I/O.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
@dataclass(frozen=True)
class AgentEvent:
"""Base class for everything a turn can report.
``type`` is the legacy string tag, kept as a class attribute so the bridge
functions can round-trip an event without a separate mapping table.
"""
type: str = field(init=False, default="event")
def to_dict(self) -> Dict[str, Any]:
"""Render into the legacy ``emit()`` dict shape."""
return {"type": self.type}
# --------------------------------------------------------------------------- #
# Assistant output
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TextChunkEvent(AgentEvent):
"""One fragment of the visible answer, as it streams in."""
delta: str
type: str = field(init=False, default="text")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class ReasoningChunkEvent(AgentEvent):
"""One fragment of the model's PRIVATE reasoning.
Drives the "Thinking" indicator only. Consumers must never append this to
the answer or persist it into conversation history - keeping it a distinct
type is what makes that mistake hard to make by accident.
"""
delta: str
type: str = field(init=False, default="reasoning")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class AssistantDoneEvent(AgentEvent):
"""One assistant message finished. A turn with tool calls emits this once
per step, not once per turn - see :class:`TurnCompletedEvent`."""
content: str = ""
type: str = field(init=False, default="assistant_done")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content}
# --------------------------------------------------------------------------- #
# Planning
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PlanUpdatedEvent(AgentEvent):
"""The agent rewrote its plan (the ``update_plan`` tool)."""
steps: Tuple[Dict[str, Any], ...] = ()
type: str = field(init=False, default="plan_set")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "steps": [dict(s) for s in self.steps]}
# --------------------------------------------------------------------------- #
# Tool lifecycle
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolCallStartedEvent(AgentEvent):
"""A tool call is about to run, with the preview shown to the user.
Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the
time it is emitted the call is already going to run unless a permission gate
rejects it, and the gate reports that as a finished call with ``ok=False``.
"""
call_id: str
name: str
args: Dict[str, Any] = field(default_factory=dict)
preview: Optional[Dict[str, Any]] = None
type: str = field(init=False, default="tool_proposed")
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id,
"name": self.name, "args": dict(self.args)}
if self.preview is not None:
out["preview"] = dict(self.preview)
return out
@dataclass(frozen=True)
class ToolOutputEvent(AgentEvent):
"""A line of live output from a running tool (command stdout, for example)."""
call_id: str
name: str
delta: str
type: str = field(init=False, default="tool_output")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "id": self.call_id, "name": self.name,
"delta": self.delta}
@dataclass(frozen=True)
class ToolCallFinishedEvent(AgentEvent):
"""A tool call ended, successfully or not.
``ok=False`` covers every failure mode alike - the tool raised, the sandbox
blocked it, or the user rejected it at the permission gate - because the
consumer's job is the same in all three: show the failure and let the model
react to it.
"""
call_id: str
name: str
ok: bool = False
output: str = ""
path: str = "" # file the tool wrote, when it wrote one
produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs)
type: str = field(init=False, default="tool_result")
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
if self.path:
out["path"] = self.path
if self.produced:
out["produced"] = list(self.produced)
return out
# --------------------------------------------------------------------------- #
# Output folder
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Files appeared in the turn's output folder."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_added")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Files were cleaned up from the turn's output folder (intermediates)."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_removed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
@dataclass(frozen=True)
class NoticeEvent(AgentEvent):
"""A UI-visible aside that is not part of the model's answer.
Three producers today, all reachable from a normal turn:
``core/agent_security.py`` (a request or command blocked by the security
layer), ``core/context_budget.py`` (the conversation was auto-compressed)
and the attachment readers (a file that could not be processed, plus live
"reading page X/Y" progress).
``level`` selects how the UI renders it: ``"progress"`` updates the thinking
indicator in place, anything else becomes a warning bubble. Dropping these
would silently hide security warnings from the user, which is why the type
exists rather than being folded into TextChunkEvent.
"""
text: str
level: str = "info"
type: str = field(init=False, default="notice")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "level": self.level, "text": self.text}
@dataclass(frozen=True)
class HistoryReadyEvent(AgentEvent):
"""A history session exists for this run and can be opened."""
session_id: str
type: str = field(init=False, default="history_ready")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "session_id": self.session_id}
# --------------------------------------------------------------------------- #
# Turn lifecycle - emitted by the application service, not by the legacy engine
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TurnCompletedEvent(AgentEvent):
"""The whole turn finished: no more events will follow.
New in R04. The legacy engine has no end-of-turn signal at all, so every
consumer infers "done" from the worker thread finishing - which is why a
cancelled turn and a failed turn look identical to the UI today.
"""
content: str = ""
cancelled: bool = False
type: str = field(init=False, default="turn_completed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content, "cancelled": self.cancelled}
@dataclass(frozen=True)
class ErrorEvent(AgentEvent):
"""The turn failed. ``recoverable`` marks errors the user can act on
(pick another model, shorten the prompt) rather than a hard outage."""
message: str
recoverable: bool = False
type: str = field(init=False, default="error")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "message": self.message,
"recoverable": self.recoverable}
# The legacy tag -> event class map. Also the authoritative list of what a turn
# can emit, which is what makes an exhaustive consumer possible for the first time.
EVENT_TYPES: Dict[str, type] = {
"text": TextChunkEvent,
"reasoning": ReasoningChunkEvent,
"assistant_done": AssistantDoneEvent,
"plan_set": PlanUpdatedEvent,
"tool_proposed": ToolCallStartedEvent,
"tool_start": ToolCallStartedEvent,
"tool_output": ToolOutputEvent,
"tool_result": ToolCallFinishedEvent,
"outputs_added": OutputsAddedEvent,
"outputs_removed": OutputsRemovedEvent,
"notice": NoticeEvent,
"history_ready": HistoryReadyEvent,
"turn_completed": TurnCompletedEvent,
"error": ErrorEvent,
}
def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]:
"""Adapt one legacy ``emit()`` dict into a typed event.
Returns ``None`` for an unknown tag instead of raising: the legacy engine is
still being refactored and may grow an event before this module knows about
it. Dropping an unrecognised event degrades the UI by one missing bubble;
raising here would abort a turn that had otherwise succeeded.
"""
kind = str(payload.get("type", ""))
cls = EVENT_TYPES.get(kind)
if cls is None:
return None
if cls is TextChunkEvent or cls is ReasoningChunkEvent:
return cls(delta=str(payload.get("delta", "")))
if cls is AssistantDoneEvent:
return AssistantDoneEvent(content=str(payload.get("content", "")))
if cls is PlanUpdatedEvent:
return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ()))
if cls is ToolCallStartedEvent:
return ToolCallStartedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
args=dict(payload.get("args") or {}), preview=payload.get("preview"),
)
if cls is ToolOutputEvent:
return ToolOutputEvent(call_id=str(payload.get("id", "")),
name=str(payload.get("name", "")),
delta=str(payload.get("delta", "")))
if cls is ToolCallFinishedEvent:
return ToolCallFinishedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")),
path=str(payload.get("path", "") or ""),
produced=tuple(payload.get("produced") or ()),
)
if cls is OutputsAddedEvent or cls is OutputsRemovedEvent:
return cls(paths=tuple(str(p) for p in (payload.get("paths") or ())))
if cls is NoticeEvent:
return NoticeEvent(text=str(payload.get("text", "")),
level=str(payload.get("level", "info")))
if cls is HistoryReadyEvent:
return HistoryReadyEvent(session_id=str(payload.get("session_id", "")))
if cls is TurnCompletedEvent:
return TurnCompletedEvent(content=str(payload.get("content", "")),
cancelled=bool(payload.get("cancelled", False)))
return ErrorEvent(message=str(payload.get("message", "")),
recoverable=bool(payload.get("recoverable", False)))
def collect_text(events: Sequence[AgentEvent]) -> str:
"""Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded.
Provided here so no consumer has to re-derive "which events are the answer",
the question the untyped dicts made easy to get wrong.
"""
return "".join(e.delta for e in events if isinstance(e, TextChunkEvent))
def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]:
"""Every finished tool call, in order - for audit views and assertions."""
return [e for e in events if isinstance(e, ToolCallFinishedEvent)]
__all__ = [
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"EVENT_TYPES",
"event_from_dict",
"collect_text",
"tool_calls",
]
@@ -0,0 +1,192 @@
"""ConversationExecutionRequest - an immutable snapshot of one turn (R04-T01).
``ui/cowork_tab.py::build_job`` currently builds a closure that reads widget
state from inside the worker thread::
def job(worker):
provider = self.build_provider() # reads combo boxes
extra_tools, extra_exec = self.ctx.build_mcp_tools()
proj_ctx = project_context_text(load_project(project_id))
...
Everything that closure touches can change while the turn is running: the user
can pick another model, switch workspace, or edit the project instructions. The
turn then runs on a mixture of old and new state, and which mixture depends on
thread timing - the class of bug that reproduces once a week and never in a test.
This value object is the fix: the presentation layer captures everything a turn
needs ON THE UI THREAD, at submit time, into one frozen object. Whatever happens
to the widgets afterwards, the turn keeps running on the state the user actually
submitted.
Pure domain code: stdlib only, no Qt, no filesystem access. Paths are held as
strings, not ``Path`` objects, so the snapshot stays trivially serialisable -
which is what will let a turn be queued, replayed or logged later.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
# Default tool-use budget for an interactive turn, and the higher ceiling a
# run-to-completion step (a Co4E flow step) is allowed. Same numbers
# ``core.chat_agent.run_cowork`` defaults to - kept here so the policy is
# visible in the request rather than buried in a function signature.
DEFAULT_MAX_STEPS = 30
DEFAULT_COMPLETION_MAX_STEPS = 200
def new_turn_id() -> str:
"""A fresh turn id. Short and random: it only has to be unique within a
session's lifetime, and it shows up in log lines humans read."""
return uuid.uuid4().hex[:12]
@dataclass(frozen=True)
class ConversationExecutionRequest:
"""Everything one agent turn needs, captured at submit time.
Attributes:
prompt: the user's message for this turn (already assembled, including
any attachment text the UI inlined).
messages: the full conversation to send, oldest first. Held as a tuple
so the snapshot cannot be mutated after capture; use
:meth:`message_list` to get the mutable copy the engine expects.
output_dir: this turn's OWN folder. Each turn writes into an isolated
directory so parallel turns cannot clobber each other's files.
session_id: the conversation this turn belongs to.
turn_id: unique per turn, for logs and for matching events to a turn.
surface: which screen submitted it ("cowork", "co4e", "ai_edit", "task").
provider / model: what to run on, already resolved (routing included).
Empty ``model`` means "the provider's configured default".
title: conversation title, used to name generated files.
project_id / project_context: the workspace and its shared instructions,
snapshotted so a mid-turn workspace switch cannot change them.
agent_role: audit-log attribution for every tool call this turn makes.
allowed_tools: permission scope. ``None`` means "all enabled tools";
a list restricts the ADVERTISED catalogue, so a read-only step
literally cannot be offered a writing tool.
max_steps / run_to_completion / completion_max_steps: tool-use budget.
enforce_rules: run the security rulebase. Co4E sandboxed runs disable it.
confirm_commands: ask before run_command/install_package (permission gate).
metadata: free-form extras a caller wants carried along (never
interpreted here) - e.g. a scheduled task's id.
"""
prompt: str
messages: Tuple[Mapping[str, Any], ...] = ()
output_dir: str = ""
session_id: str = ""
turn_id: str = field(default_factory=new_turn_id)
surface: str = "cowork"
provider: str = ""
model: str = ""
title: str = ""
project_id: str = ""
project_context: str = ""
agent_role: str = ""
allowed_tools: Optional[Tuple[str, ...]] = None
max_steps: int = DEFAULT_MAX_STEPS
run_to_completion: bool = False
completion_max_steps: int = DEFAULT_COMPLETION_MAX_STEPS
enforce_rules: bool = True
confirm_commands: bool = False
metadata: Mapping[str, Any] = field(default_factory=dict)
# -- construction helpers ------------------------------------------- #
@classmethod
def create(cls, prompt: str, messages: Optional[Sequence[Mapping[str, Any]]] = None,
**kwargs: Any) -> "ConversationExecutionRequest":
"""Build a request from ordinary mutable inputs.
The messages list is copied element by element, so a later append by the
caller (the chat panel keeps appending to its own list) cannot reach
inside a request that is already running.
"""
snapshot = tuple(dict(m) for m in (messages or ()))
allowed = kwargs.pop("allowed_tools", None)
return cls(prompt=prompt, messages=snapshot,
allowed_tools=tuple(allowed) if allowed is not None else None,
**kwargs)
def with_messages(self, messages: Sequence[Mapping[str, Any]]
) -> "ConversationExecutionRequest":
"""A copy carrying a different message list, everything else unchanged.
Used when a caller assembles the system prompt or trims history after
building the request - it must produce a NEW snapshot rather than mutate
the one a turn may already be running on.
"""
return replace(self, messages=tuple(dict(m) for m in messages))
def with_model(self, provider: str, model: str) -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model - how a routing switch is
applied without touching the user's saved settings."""
return replace(self, provider=provider, model=model)
# -- accessors ------------------------------------------------------ #
def message_list(self) -> List[Dict[str, Any]]:
"""A fresh mutable copy of the messages, for the engine to append to.
The legacy engine mutates the list it is given (it inserts the system
prompt and appends assistant/tool messages). Handing it a copy is what
keeps this snapshot immutable in practice and not just by declaration.
"""
return [dict(m) for m in self.messages]
@property
def effective_max_steps(self) -> int:
"""The tool-use ceiling actually in force for this turn."""
return self.completion_max_steps if self.run_to_completion else self.max_steps
@property
def has_output_dir(self) -> bool:
"""True when this turn may write files."""
return bool(self.output_dir)
def allows_tool(self, name: str) -> bool:
"""Whether ``name`` is inside this turn's permission scope.
``update_plan`` is always allowed: it has no side effects and drives the
Plan panel, so scoping it out would silently break the UI rather than
restrict a capability.
"""
if self.allowed_tools is None:
return True
return name == "update_plan" or name in self.allowed_tools
def describe(self) -> str:
"""Compact one-line identity for log lines."""
target = f"{self.provider}/{self.model}" if self.model else self.provider or "default"
return f"turn={self.turn_id} surface={self.surface} model={target}"
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for logging a turn or persisting it for replay."""
return {
"turn_id": self.turn_id,
"session_id": self.session_id,
"surface": self.surface,
"prompt": self.prompt,
"message_count": len(self.messages),
"output_dir": self.output_dir,
"provider": self.provider,
"model": self.model,
"title": self.title,
"project_id": self.project_id,
"agent_role": self.agent_role,
"allowed_tools": list(self.allowed_tools) if self.allowed_tools is not None else None,
"max_steps": self.effective_max_steps,
"run_to_completion": self.run_to_completion,
"enforce_rules": self.enforce_rules,
"confirm_commands": self.confirm_commands,
"metadata": dict(self.metadata),
}
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"DEFAULT_MAX_STEPS",
"DEFAULT_COMPLETION_MAX_STEPS",
]
+5
View File
@@ -0,0 +1,5 @@
"""Domain models: provider/model catalogue value objects (EPIC R03)."""
from .provider_descriptor import ProviderCapability, ProviderDescriptor
__all__ = ["ProviderDescriptor", "ProviderCapability"]
+171
View File
@@ -0,0 +1,171 @@
"""ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
Today the knowledge of "what a provider is" is scattered across three places
that must be edited together and can silently drift apart:
* ``providers/factory.py::_REGISTRY`` - name -> implementation class
* ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
* ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
Adding a provider means remembering all three; forgetting one produces a
provider that exists but has no label, or a label with no implementation. This
value object folds those facts into a single immutable description that the
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
read, so a new provider is declared once.
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
a provider; building one is infrastructure's job.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
class ProviderCapability(str, Enum):
"""What a provider can do, as advertised by its descriptor.
Kept as a closed enum rather than free-form strings so a typo
(``"vison"``) fails at import time instead of silently disabling a feature
at runtime. Inherits ``str`` so existing dict/JSON code that compares against
plain strings keeps working during the migration.
"""
STREAMING = "streaming" # can stream answer fragments through on_text
TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
VISION = "vision" # accepts image content blocks (see providers/base.py)
REASONING = "reasoning" # emits a separate private "thinking" stream
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
@dataclass(frozen=True)
class ProviderDescriptor:
"""An immutable description of one provider the app can talk to.
Attributes:
id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
of a routing candidate key (``provider/model_id``).
label: human-readable name for Settings and the model picker.
protocol: which wire format this provider speaks. Several ids share one
protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
OpenAI-compatible endpoints - which is exactly why protocol and id
must be separate fields.
default_model: the model used when the user has not chosen one.
capabilities: what the provider supports (see :class:`ProviderCapability`).
requires_api_key: whether an empty ``api_key`` makes it unusable.
requires_base_url: whether an empty ``base_url`` makes it unusable.
local: True when the endpoint runs on the user's own machine. Routing
treats local models as zero-cost, and the security layer treats them
as not leaving the machine, so this is a real behavioural flag and
not just documentation.
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
"""
id: str
label: str
protocol: str
default_model: str = ""
capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
requires_api_key: bool = True
requires_base_url: bool = True
local: bool = False
notes: str = ""
# -- capability queries ---------------------------------------------- #
def supports(self, capability: ProviderCapability) -> bool:
"""True when this provider advertises ``capability``."""
return capability in self.capabilities
@property
def supports_vision(self) -> bool:
"""Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
the descriptor (no instance, no network) before building a provider."""
return self.supports(ProviderCapability.VISION)
@property
def supports_tools(self) -> bool:
"""True when this provider can run an agent turn with tools. A provider
without it can still chat, but must never be routed a tool-using task."""
return self.supports(ProviderCapability.TOOLS)
def capability_names(self) -> List[str]:
"""Capabilities as sorted plain strings - the shape the routing layer's
``required_capabilities`` filter and the assessment store both use."""
return sorted(c.value for c in self.capabilities)
# -- configuration validation ---------------------------------------- #
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
"""Which required config keys are absent or blank in ``conf``.
Returned as a list (not a bool) so Settings can tell the user exactly
what to fill in, instead of a generic "not configured". A provider that
needs nothing returns an empty list.
"""
missing: List[str] = []
if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
missing.append("api_key")
if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
missing.append("base_url")
return missing
def is_configured(self, conf: Mapping[str, Any]) -> bool:
"""True when ``conf`` carries everything this provider needs to run."""
return not self.missing_settings(conf)
def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
requested: str = "") -> str:
"""Pick the model id for a call: explicit request, else configured, else
this descriptor's default.
Centralised here because the same three-step fallback is currently
re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
and each of them gets the precedence subtly different.
"""
if requested:
return requested
configured = str((conf or {}).get("model", "") or "").strip()
return configured or self.default_model
def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line summary for logs and the Settings row, e.g.
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
def candidate_key(self, model_id: str) -> str:
"""The ``provider/model_id`` identity the routing layer keys on.
Defined here so the domain owns the format; ``core.routing.models`` has
its own ``candidate_key()`` helper producing the identical string, and
keeping them equal is what lets the new registry and the existing
assessment store share one keyspace during the migration.
"""
return f"{self.id}/{model_id}"
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for persisting a catalogue snapshot or sending
the descriptor to a UI layer that must not import domain types."""
return {
"id": self.id,
"label": self.label,
"protocol": self.protocol,
"default_model": self.default_model,
"capabilities": self.capability_names(),
"requires_api_key": self.requires_api_key,
"requires_base_url": self.requires_base_url,
"local": self.local,
"notes": self.notes,
}
def split_candidate_key(key: str) -> Tuple[str, str]:
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
a slash (``org/model``), and splitting on the last one would corrupt them.
"""
provider, _, model_id = key.partition("/")
return provider, model_id
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]
+5
View File
@@ -0,0 +1,5 @@
"""Domain entities for schedule/due-time computation (EPIC R07)."""
from .schedule_calculator import ScheduleCalculator
__all__ = ["ScheduleCalculator"]
+165
View File
@@ -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"]
+18
View File
@@ -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",
]
+86
View File
@@ -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"]
+125
View File
@@ -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",
]
+5
View File
@@ -0,0 +1,5 @@
"""Domain entities for workspace/project isolation (EPIC R06)."""
from .workspace_session import WorkspaceSession
__all__ = ["WorkspaceSession"]
+96
View File
@@ -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"]
+7
View File
@@ -0,0 +1,7 @@
"""Infrastructure layer - adapters to the outside world.
Concrete implementations of what the inner layers only describe: HTTP calls to
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
May import ``domain/`` (to speak its types) and third-party libraries, but never
``presentation/``/``ui/``.
"""
+6
View File
@@ -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"]
+110
View File
@@ -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"]
+55
View File
@@ -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"]
+136
View File
@@ -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"]
+62
View 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"]
+5
View File
@@ -0,0 +1,5 @@
"""MCP server connection lifecycle management (EPIC R05)."""
from .mcp_source_manager import McpToolSourceManager
__all__ = ["McpToolSourceManager"]
+113
View File
@@ -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"]
+1
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
"""Provider adapters and the central provider catalogue (EPIC R03)."""
from .provider_registry import ProviderRegistry, default_registry
__all__ = ["ProviderRegistry", "default_registry"]
@@ -0,0 +1,207 @@
"""ProviderRegistry - the one place a provider is declared (R03-T02).
Replaces the three-way split between ``providers/factory.py::_REGISTRY``,
``config.py::DEFAULT_CONFIG["providers"]`` and ``config.py::PROVIDER_LABELS``
with a single catalogue of :class:`ProviderDescriptor` objects plus the
implementation class each one maps to.
Adding a provider is now one entry in :data:`BUILT_IN_PROVIDERS` (declarative
facts) and one line in :data:`_IMPLEMENTATIONS` (which class speaks that
protocol) - see ``docs/governance/contributor-recipes.md`` (R10-T04).
Migration note (strangler fig, ADR-001 section 4): this registry does not
re-implement any provider. It builds the SAME classes ``providers/factory.py``
builds, so both entry points stay behaviourally identical while call sites move
over one at a time.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping, Optional
from cowork_local.domain.models.provider_descriptor import (
ProviderCapability,
ProviderDescriptor,
)
from cowork_local.providers.base import Provider, ProviderError
_CAP = ProviderCapability
# Every provider the app ships with, described once.
#
# The capability sets are deliberately conservative: a capability listed here is
# one the adapter genuinely implements today. Claiming VISION for a provider
# whose chat() cannot translate an image block would route an image turn into a
# guaranteed failure, so an unimplemented capability must stay off the list.
BUILT_IN_PROVIDERS: tuple = (
ProviderDescriptor(
id="openai_compat",
label="OpenAI-compatible (Internal Gateway)",
protocol="openai_compat",
default_model="gpt-4o-mini",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.REASONING, _CAP.MODEL_LISTING}),
notes="Any endpoint speaking the OpenAI Chat Completions protocol.",
),
ProviderDescriptor(
id="anthropic",
label="Anthropic Claude",
protocol="anthropic",
default_model="claude-sonnet-4-6",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.MODEL_LISTING}),
),
ProviderDescriptor(
id="ollama",
label="Ollama (local models)",
protocol="openai_compat",
default_model="llama3.1",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.REASONING,
_CAP.MODEL_LISTING}),
# Ollama ignores the key, but the OpenAI client layer requires a value,
# so the default config ships a placeholder rather than an empty string.
requires_api_key=False,
local=True,
notes="Runs on this machine - no data leaves the device, no token cost.",
),
ProviderDescriptor(
id="github_copilot",
label="GitHub Copilot",
protocol="openai_compat",
default_model="gpt-4o",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.MODEL_LISTING}),
notes="Paste a Copilot token as the API key.",
),
ProviderDescriptor(
id="codex",
label="OpenAI (Codex / GPT)",
protocol="openai_compat",
default_model="gpt-4o-mini",
capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
_CAP.REASONING, _CAP.MODEL_LISTING}),
),
)
def _implementations() -> Dict[str, type]:
"""Protocol -> adapter class.
Imported lazily inside the function because ``providers/anthropic.py`` and
``providers/openai_compat.py`` pull in ``requests`` at import time; keeping
that out of module import means a test that only inspects descriptors pays
no import cost at all.
"""
from cowork_local.providers.anthropic import AnthropicProvider
from cowork_local.providers.openai_compat import OpenAICompatProvider
return {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
}
class ProviderRegistry:
"""Catalogue of known providers + the factory that instantiates them.
Intentionally holds no config and no app context: it is a pure lookup table
plus a build step, so it can be constructed in a test with a custom
descriptor list and no application running.
"""
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
# Dict preserves declaration order (Python 3.7+), which is the order
# Settings lists providers in - so the catalogue order is data, not luck.
self._by_id: Dict[str, ProviderDescriptor] = {
d.id: d for d in (descriptors if descriptors is not None else BUILT_IN_PROVIDERS)
}
# -- catalogue queries ------------------------------------------------ #
def ids(self) -> List[str]:
"""Known provider ids, in declaration order."""
return list(self._by_id)
def all(self) -> List[ProviderDescriptor]:
"""Every descriptor, in declaration order."""
return list(self._by_id.values())
def get(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""The descriptor for ``provider_id``, or None when unknown.
Returns None rather than raising because the caller is often reacting to
a config file that may name a provider from a newer version; the UI
should be able to skip it, not crash.
"""
return self._by_id.get(provider_id)
def require(self, provider_id: str) -> ProviderDescriptor:
"""Like :meth:`get` but raises :class:`ProviderError` when unknown.
Same error type ``providers/factory.py::build_provider`` already raises,
so callers that migrate to the registry keep their existing except clause.
"""
descriptor = self._by_id.get(provider_id)
if descriptor is None:
known = ", ".join(self._by_id) or "(none)"
raise ProviderError(f"Unsupported provider: {provider_id} (known: {known})")
return descriptor
def labels(self) -> Dict[str, str]:
"""``{id: label}`` - the drop-in replacement for ``config.PROVIDER_LABELS``."""
return {d.id: d.label for d in self._by_id.values()}
def supporting(self, capability: ProviderCapability) -> List[ProviderDescriptor]:
"""Every descriptor advertising ``capability`` - used to answer "which
providers could serve this turn?" before any of them is built."""
return [d for d in self._by_id.values() if d.supports(capability)]
def configured(self, providers_conf: Mapping[str, Mapping[str, Any]]
) -> List[ProviderDescriptor]:
"""Descriptors whose config section is complete enough to actually call.
``providers_conf`` is ``AppConfig.data["providers"]``. Passing the raw
mapping (not the AppConfig object) keeps this layer independent of the
config implementation, which EPIC R02 is rewriting in parallel.
"""
return [d for d in self._by_id.values()
if d.is_configured(providers_conf.get(d.id, {}) or {})]
# -- construction ----------------------------------------------------- #
def build(self, provider_id: str, conf: Mapping[str, Any],
model: str = "") -> Provider:
"""Instantiate the adapter for ``provider_id``.
``model`` overrides the configured model for this instance only - that is
how the routing layer runs one turn on a different model without mutating
the user's saved settings.
"""
descriptor = self.require(provider_id)
impl = _implementations().get(descriptor.protocol)
if impl is None: # pragma: no cover - only reachable via a bad descriptor
raise ProviderError(
f"Provider '{provider_id}' declares unknown protocol "
f"'{descriptor.protocol}'."
)
# Copy before mutating: conf is the caller's live config dict, and
# writing the routed model into it would silently change the user's
# saved default for every later turn.
resolved = dict(conf or {})
resolved["model"] = descriptor.resolve_model(conf, model)
instance = impl(resolved)
# The adapter class is shared by several ids (three of them are
# OpenAI-compatible), so its class-level `name` cannot identify which
# provider this is. Stamping the instance keeps usage records, audit
# entries and routing candidate keys attributed to the right provider.
instance.name = descriptor.id
return instance
def describe(self, provider_id: str, conf: Optional[Mapping[str, Any]] = None) -> str:
"""One-line description used in logs and error messages."""
return self.require(provider_id).describe(conf)
# Shared default instance. Callers that need the built-in catalogue use this
# instead of constructing a registry each time; tests build their own with an
# explicit descriptor list.
default_registry = ProviderRegistry()
__all__ = ["ProviderRegistry", "BUILT_IN_PROVIDERS", "default_registry"]
+18
View File
@@ -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"]
+70
View File
@@ -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"]
+21
View File
@@ -0,0 +1,21 @@
"""Telemetry sinks: where token usage and turn metrics are recorded (EPIC R03)."""
from .usage_sink import (
NullUsageSink,
RecordingUsageSink,
UsageEvent,
UsageEventSink,
UsageTrackerSink,
default_sink,
set_default_sink,
)
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"NullUsageSink",
"RecordingUsageSink",
"default_sink",
"set_default_sink",
]
+229
View File
@@ -0,0 +1,229 @@
"""UsageEventSink - where a turn's token usage goes (R03-T06).
Today each provider records its own usage inline, in the middle of the streaming
loop::
# providers/openai_compat.py
def _record_usage(self, messages, text_parts, tool_acc, usage_seen):
from ..core import usage_tracker as ut
...
ut.record(self.name, self.model, ...)
Three problems with that shape:
1. **Hidden side effect.** ``chat()`` looks like a pure request/response call but
also writes to the Dashboard's store, so a test of a provider silently
appends rows to the developer's real usage history.
2. **Duplicated estimation.** The "no usage block from the server, so estimate
at ~4 chars/token" fallback is copy-pasted per provider and can drift.
3. **One hard-wired destination.** Usage can only ever go to
``core.usage_tracker``; a run that wants to bill a workflow, or a test that
wants to assert on token counts, has nowhere to plug in.
This module introduces the seam: providers build a :class:`UsageEvent` and hand
it to a :class:`UsageEventSink`. Production wires :class:`UsageTrackerSink`
(same destination, same numbers as before); tests wire
:class:`RecordingUsageSink` or :class:`NullUsageSink`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Protocol, Sequence
logger = logging.getLogger("cowork_local.telemetry")
# Rough characters-per-token ratio used when the gateway sends no usage block.
# Matches the constant behaviour of ``core.usage_tracker.estimate_tokens`` so
# moving the estimation here does not change a single recorded number.
_CHARS_PER_TOKEN = 4
@dataclass(frozen=True)
class UsageEvent:
"""Token usage for exactly one provider round trip.
``estimated`` marks a record derived from text length rather than reported by
the server. The Dashboard shows the two differently, and conflating them
would make cost figures look more precise than they are.
"""
provider: str
model: str
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
estimated: bool = False
@property
def total_tokens(self) -> int:
"""Input + output. Cached tokens are a subset of input, not an addition,
so adding them here would double-count a cache hit."""
return self.input_tokens + self.output_tokens
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection for logs and for sinks that persist raw events."""
return {
"provider": self.provider,
"model": self.model,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cached_tokens": self.cached_tokens,
"estimated": self.estimated,
}
class UsageEventSink(Protocol):
"""Anything that can absorb a :class:`UsageEvent`.
Implementations MUST NOT raise: telemetry is observability, and a failure to
record usage must never abort the turn that produced it.
"""
def record(self, event: UsageEvent) -> None:
"""Absorb one usage event."""
class NullUsageSink:
"""Discards everything. The default for tests and headless tooling, so a
unit test never writes into the developer's real usage history."""
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
return None
class RecordingUsageSink:
"""Keeps events in memory so a test can assert on what was recorded."""
def __init__(self) -> None:
self.events: List[UsageEvent] = []
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
self.events.append(event)
@property
def total_tokens(self) -> int:
"""Sum across every recorded event."""
return sum(e.total_tokens for e in self.events)
class UsageTrackerSink:
"""Forwards to ``core.usage_tracker`` - the Dashboard's store.
This is the production sink and the only place that still knows about the
legacy tracker module, which is what lets EPIC R10 replace the storage
without touching a single provider.
"""
def __init__(self, tracker: Optional[Any] = None) -> None:
# Injectable for tests; imported lazily otherwise because the tracker
# touches the config directory at import time.
self._tracker = tracker
def _resolve(self) -> Any:
if self._tracker is None:
from cowork_local.core import usage_tracker
self._tracker = usage_tracker
return self._tracker
def record(self, event: UsageEvent) -> None:
"""Write the event to the usage tracker, swallowing any failure.
The bare except mirrors the behaviour this replaces (each provider
already wrapped its ``ut.record`` call in ``try/except: pass``) but logs
at debug level instead of discarding the reason entirely, so a broken
Dashboard store can at least be diagnosed.
"""
try:
self._resolve().record(
event.provider, event.model,
event.input_tokens, event.output_tokens, event.cached_tokens,
estimated=event.estimated,
)
except Exception: # noqa: BLE001 - telemetry must never break a turn
logger.debug("usage sink: failed to record %s", event.to_dict(), exc_info=True)
def estimate_tokens(text: str) -> int:
"""Approximate token count for ``text`` (~4 characters per token).
Deliberately identical to ``core.usage_tracker.estimate_tokens`` so that
moving estimation into this layer changes no recorded number. Duplicated
rather than imported to keep this module free of the legacy dependency;
:class:`UsageTrackerSink` is the only bridge back to it.
"""
return max(0, len(text or "") // _CHARS_PER_TOKEN)
def estimated_event(provider: str, model: str, sent: str, received: str) -> UsageEvent:
"""Build an estimated :class:`UsageEvent` from the raw text of a round trip.
Used when the gateway sends no usage block - most self-hosted OpenAI-compatible
servers and Ollama do not.
"""
return UsageEvent(
provider=provider, model=model,
input_tokens=estimate_tokens(sent),
output_tokens=estimate_tokens(received),
cached_tokens=0,
estimated=True,
)
def openai_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
"""Build a reported :class:`UsageEvent` from an OpenAI-style usage block."""
details = usage.get("prompt_tokens_details") or {}
return UsageEvent(
provider=provider, model=model,
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
cached_tokens=int(details.get("cached_tokens", 0) or 0),
estimated=False,
)
def anthropic_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
"""Build a reported :class:`UsageEvent` from Anthropic's usage accumulator.
Anthropic reports input tokens on ``message_start`` and output tokens on
``message_delta``, so ``providers/anthropic.py`` accumulates them into a dict
keyed ``in``/``out``/``cache`` - this reads that shape.
"""
return UsageEvent(
provider=provider, model=model,
input_tokens=int(usage.get("in", 0) or 0),
output_tokens=int(usage.get("out", 0) or 0),
cached_tokens=int(usage.get("cache", 0) or 0),
estimated=False,
)
# The sink providers use unless one is injected. A module-level default keeps
# the change to the provider classes to a single attribute, and lets a test swap
# the destination process-wide with one monkeypatch.
default_sink: UsageEventSink = UsageTrackerSink()
def set_default_sink(sink: UsageEventSink) -> UsageEventSink:
"""Replace the process-wide default sink; returns the previous one so a
caller (or fixture) can restore it."""
global default_sink
previous = default_sink
default_sink = sink
return previous
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"NullUsageSink",
"RecordingUsageSink",
"estimate_tokens",
"estimated_event",
"openai_usage_event",
"anthropic_usage_event",
"default_sink",
"set_default_sink",
]
+7
View File
@@ -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.
"""
+3
View File
@@ -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."""
+91
View File
@@ -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"]
+171
View File
@@ -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"]
+4
View File
@@ -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"]
+372
View File
@@ -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"]
+190
View File
@@ -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"]
+120
View File
@@ -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"]
+3
View File
@@ -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."""
+99
View File
@@ -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"]
+374
View File
@@ -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"]
+391
View File
@@ -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"]
+142
View File
@@ -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"]
+3
View File
@@ -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"]
+14
View File
@@ -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"]
+38
View File
@@ -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"]
+12 -14
View File
@@ -292,21 +292,19 @@ class AnthropicProvider(Provider):
args = {"_raw": b["json"]}
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
# Dashboard usage event — real counts from the stream's usage events,
# else a ~4 chars/token estimate. Never breaks the turn.
try:
from ..core import usage_tracker as ut
# Dashboard usage event — real counts from the stream's usage events
# (input arrives on message_start, output on message_delta), else a
# ~4 chars/token estimate. Delivery is the sink's job (R03-T06), so this
# only translates Anthropic's wire shape into a canonical UsageEvent.
from ..infrastructure.telemetry import usage_sink as telemetry
if usage_seen:
ut.record(self.name, self.model, usage_seen.get("in", 0),
usage_seen.get("out", 0), usage_seen.get("cache", 0))
else:
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
ut.record(self.name, self.model, ut.estimate_tokens(sent),
ut.estimate_tokens(got), 0, estimated=True)
except Exception: # noqa: BLE001
pass
if usage_seen:
event = telemetry.anthropic_usage_event(self.name, self.model, usage_seen)
else:
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
event = telemetry.estimated_event(self.name, self.model, sent, got)
self._emit_usage(event)
return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls}
+24
View File
@@ -224,6 +224,12 @@ class Provider:
# silently swallowing the error — Settings' "Test connection" / "Load
# models" surfaces this so "model won't load" has a concrete reason.
self.last_error = ""
# Where this provider's token usage goes (R03-T06). None means "the
# process-wide default sink", resolved lazily in _emit_usage so that a
# test can swap the destination without rebuilding every provider.
# Set it per instance to bill one run somewhere else (a workflow, a
# scheduled task) without touching global state.
self.usage_sink = None
def chat(
self,
@@ -274,6 +280,24 @@ class Provider:
return True, f"OK — {len(models)} model(s) available."
return False, "No models returned. Check base_url/API key and network access."
# -- telemetry -----------------------------------------------------
def _emit_usage(self, event) -> None:
"""Hand one ``UsageEvent`` to this provider's usage sink.
Never raises: recording how many tokens a turn cost must not be able to
fail the turn itself. Falls back to the process-wide default sink so
existing call sites keep reporting to the Dashboard exactly as before
(see infrastructure/telemetry/usage_sink.py)."""
try:
sink = self.usage_sink
if sink is None:
from ..infrastructure.telemetry import usage_sink as telemetry
sink = telemetry.default_sink
sink.record(event)
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
pass
# -- shared helpers ------------------------------------------------
@staticmethod
def _is_cancelled(cancel) -> bool:
+18 -15
View File
@@ -268,22 +268,25 @@ class OpenAICompatProvider(Provider):
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
"""One Dashboard usage event per turn: real counts when the server's
final chunk carried a "usage" block, a ~4 chars/token estimate
otherwise. Never breaks the turn."""
try:
from ..core import usage_tracker as ut
otherwise.
if usage_seen:
ut.record(self.name, self.model,
usage_seen.get("prompt_tokens", 0),
usage_seen.get("completion_tokens", 0),
(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
else:
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
ut.record(self.name, self.model, ut.estimate_tokens(sent),
ut.estimate_tokens(got), 0, estimated=True)
except Exception: # noqa: BLE001
pass
Building the event and delivering it are now separate concerns (R03-T06):
this method only translates THIS provider's wire shape into a canonical
``UsageEvent``; where it ends up is the sink's decision, so a test can
assert on token counts without writing to the real Dashboard store."""
from ..infrastructure.telemetry import usage_sink as telemetry
if usage_seen:
event = telemetry.openai_usage_event(self.name, self.model, usage_seen)
else:
# No usage block from the gateway (self-hosted servers and Ollama
# never send one) - fall back to estimating from the raw text of
# both directions, tool-call arguments included since the model was
# billed for generating them.
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
event = telemetry.estimated_event(self.name, self.model, sent, got)
self._emit_usage(event)
def list_models(self):
self.last_error = ""
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""CASAN Check 3 — Clean Architecture Guard (R01-T03).
Statically walks the AST of every Python file in the pure-Python layers and
fails when a file imports something the layer is not allowed to depend on.
Why AST instead of ``grep``: a regex over source text cannot tell an import
apart from the same words appearing inside a docstring, a comment or a string
literal (this repo has several docstrings that legitimately mention
``PySide6``). ``ast`` sees only real ``import`` / ``from … import`` nodes, so
the check has no false positives and needs no ``# noqa`` escape hatches.
Rules enforced (see docs/architecture/ADR-001-layered-architecture.md):
* **I1** ``domain/`` and ``application/`` must be 100% pure Python — no Qt.
* **I2** ``domain/`` must not import ``application/``, ``infrastructure/``,
``presentation/`` or the legacy ``ui/``.
* **I3** ``application/`` must not import ``presentation/`` or ``ui/``.
Usage::
python scripts/check_imports.py # scan the whole repo
python scripts/check_imports.py domain # scan one layer only
Exit code is 0 when clean and 1 when at least one violation is found, so it
can be wired straight into CI / ``scripts/run_quality_gate.py`` (R10-T02).
"""
from __future__ import annotations
import argparse
import ast
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Sequence, Tuple
# Repository root = parent of this scripts/ folder. Everything below is resolved
# relative to it so the checker works no matter what the checkout folder is
# named or which directory the developer runs it from.
REPO_ROOT = Path(__file__).resolve().parents[1]
# The distribution package name. Absolute imports may be written either as
# ``from cowork_local.ui import x`` or ``from ui import x`` depending on how the
# module was reached; we normalise the prefix away so both spellings are caught.
PACKAGE_NAME = "cowork_local"
# Any import whose first dotted segment is one of these is a GUI toolkit.
QT_ROOTS = frozenset({"PySide6", "PySide2", "PyQt5", "PyQt6", "shiboken6", "shiboken2"})
# Per-layer rules: layer directory -> top-level package names it may not import.
# Kept as a plain table so adding a layer later is a one-line change and the
# rules stay readable next to the ADR they implement.
LAYER_RULES: Dict[str, frozenset] = {
# I1 + I2: domain is the innermost layer and depends on nothing but stdlib.
"domain": frozenset({"application", "infrastructure", "presentation", "ui", "core"}),
# I1 + I3: application may use domain, but never anything that draws pixels.
"application": frozenset({"presentation", "ui"}),
}
# Directories that are never production code and therefore never scanned.
SKIP_DIRS = frozenset({".git", "__pycache__", ".pytest_cache", "tests", "build", "dist"})
@dataclass(frozen=True)
class Violation:
"""One forbidden import, carrying enough context to fix it without grepping."""
path: Path
line: int
imported: str
rule: str
def render(self) -> str:
"""Format as ``file:line: message`` — the shape editors turn into a
clickable link, so a CI failure lands the developer on the exact line."""
rel = self.path.relative_to(REPO_ROOT).as_posix()
# ASCII-only on purpose: this line is printed to a console that may run a
# legacy code page (cp932 on the team's Windows boxes), where a non-ASCII
# dash raises UnicodeEncodeError and would crash the gate on the very
# failure path it exists to report.
return f"{rel}:{self.line}: imports '{self.imported}' - {self.rule}"
def iter_python_files(layer_dir: Path) -> Iterable[Path]:
"""Yield every production ``.py`` file under ``layer_dir``.
Test files are excluded on purpose: a test for a pure-Python service is
allowed to import Qt (an integration test may need a headless widget), and
holding tests to the production rule would push people to disable the gate.
"""
if not layer_dir.is_dir():
return
for path in sorted(layer_dir.rglob("*.py")):
# Reject a path as soon as ANY of its parent folder names is skippable,
# which also covers nested __pycache__ inside a sub-package.
if any(part in SKIP_DIRS for part in path.parts):
continue
yield path
def module_parts(path: Path) -> List[str]:
"""Dotted package path of ``path`` relative to the repo root, as a list.
``domain/agents/agent_event.py`` -> ``["domain", "agents", "agent_event"]``
``domain/agents/__init__.py`` -> ``["domain", "agents"]``
Needed to resolve *relative* imports: ``from ..models import X`` inside
``domain/agents/foo.py`` really means ``domain.models``, and only the file's
own position tells us that.
"""
rel = path.relative_to(REPO_ROOT)
parts = list(rel.parts)
if parts[-1] == "__init__.py":
parts.pop()
else:
parts[-1] = parts[-1][: -len(".py")]
return parts
def resolve_relative(parts: Sequence[str], level: int, module: str) -> str:
"""Turn a relative import into the absolute top-level package it points at.
``level`` is the number of leading dots. Level 1 means "the package this
module lives in", so we drop the module's own name plus ``level - 1``
further parents. Returns the FIRST segment of the resolved path, because
the rules are expressed in terms of top-level layers.
Walking off the top of the tree (more dots than there are parents) yields
an empty string, which simply never matches a rule — a malformed import
like that is a syntax/packaging problem, not an architecture violation.
"""
base = list(parts[:-1]) # the package containing this module
if level > 1:
drop = level - 1
if drop > len(base):
return ""
base = base[: len(base) - drop]
tail = module.split(".") if module else []
resolved = base + tail
return resolved[0] if resolved else ""
def top_level(name: str) -> str:
"""First dotted segment of an absolute import, with the distribution package
prefix stripped so ``cowork_local.ui.chat_panel`` and ``ui.chat_panel`` are
treated as the same dependency."""
segments = name.split(".")
if segments and segments[0] == PACKAGE_NAME:
segments = segments[1:]
return segments[0] if segments else ""
def imported_roots(tree: ast.AST, parts: Sequence[str]) -> Iterable[Tuple[str, int, str]]:
"""Yield ``(top_level_package, line_number, as_written)`` for every import.
``as_written`` is kept so the error message shows what the developer
actually typed rather than the normalised root, which makes the violation
obvious at a glance.
``ast.walk`` (not just the module body) is deliberate: this repo defers many
heavy imports into function bodies to keep app start-up fast, and a
function-local ``from PySide6 import QtWidgets`` breaks the layer exactly
the same way a top-level one does.
"""
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield top_level(alias.name), node.lineno, alias.name
elif isinstance(node, ast.ImportFrom):
if node.level:
written = "." * node.level + (node.module or "")
yield resolve_relative(parts, node.level, node.module or ""), node.lineno, written
else:
module = node.module or ""
yield top_level(module), node.lineno, module
def check_file(path: Path, layer: str, banned: frozenset) -> List[Violation]:
"""Collect every rule violation in one file.
A file that cannot be parsed is reported as a violation rather than skipped:
silently passing a file the checker could not read would make the gate lie.
"""
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (SyntaxError, UnicodeDecodeError) as exc:
return [Violation(path, getattr(exc, "lineno", 0) or 0, "<unparseable>",
f"cannot be parsed by the architecture guard ({exc})")]
parts = module_parts(path)
out: List[Violation] = []
for root, lineno, written in imported_roots(tree, parts):
if root in QT_ROOTS:
out.append(Violation(path, lineno, written,
f"'{layer}/' must be 100% pure Python (ADR-001 I1)"))
elif root in banned:
out.append(Violation(path, lineno, written,
f"'{layer}/' must not depend on '{root}/' (ADR-001 I2/I3)"))
return out
def run(layers: Sequence[str]) -> List[Violation]:
"""Scan the requested layers and return every violation found, in file order."""
found: List[Violation] = []
for layer in layers:
banned = LAYER_RULES[layer]
for path in iter_python_files(REPO_ROOT / layer):
found.extend(check_file(path, layer, banned))
return found
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="CASAN Check 3 - Clean Architecture Guard (see ADR-001).")
parser.add_argument(
"layers", nargs="*", choices=sorted(LAYER_RULES) or None, default=None,
help="Layers to scan (default: every layer with a rule).",
)
args = parser.parse_args(argv)
layers = args.layers or sorted(LAYER_RULES)
violations = run(layers)
scanned = sum(1 for layer in layers for _ in iter_python_files(REPO_ROOT / layer))
if violations:
print(f"FAIL - {len(violations)} architecture violation(s) in {scanned} file(s):\n")
for v in violations:
print(" " + v.render())
# Point at the rationale instead of just the rule id, so someone hitting
# this for the first time knows where the decision was made.
print("\nSee docs/architecture/ADR-001-layered-architecture.md")
return 1
print(f"PASS - 0 Qt imports in {', '.join(layers)} ({scanned} file(s) scanned)")
return 0
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More