From e29a0ccdbd31d1e630ed9557322a69abf0dceb8f Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:41:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20v=C3=A1=204=20h=E1=BB=93i=20quy,=20?= =?UTF-8?q?t=C3=A1ch=204=20file=20ch=E1=BA=A1m=20tr=E1=BA=A7n=20LOC,=20doc?= =?UTF-8?q?string=20l=C3=AAn=20100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hồi quy đã vá ------------- F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều gắn 1 tệp, khớp bản trước refactor. F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu tiên hỏng thì đổi provider chính là lúc phải thử lại. F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)` mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px. `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor. F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()` và không bao giờ chạy. Tách file (F-09) ---------------- Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật: graph_renderer.py -> graph_scene_builder.py + graph_export.py co4e_workflow_service.py -> co4e_run_history.py json_config_repository.py -> config_sections.py agents_admin_tab.py -> shared/agent_kind_visuals.py File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`, giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau giữa các bảng Giám sát nữa. Docstring --------- 41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng. Seam chưa nối dây (F-05) ------------------------ 9 seam mang nhãn `SEAM · dựng ` kèm hai câu: được nối khi nào, và để dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O đọc nhãn đó và nhắc khi quá 30 ngày. 859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ. Co-Authored-By: Claude Opus 5 (1M context) --- __main__.py | 7 + app.py | 7 + .../conversation_application_service.py | 6 + .../conversations/core_runtime_adapter.py | 12 ++ .../conversations/tool_policy_gateway.py | 10 +- .../model_routing/core_routing_adapter.py | 4 + .../routing_application_service.py | 4 + .../monitoring/dashboard_query_service.py | 8 ++ application/monitoring/dto/__init__.py | 3 + application/monitoring/dto/audit_event_dto.py | 4 + .../monitoring/monitoring_query_service.py | 6 + application/monitoring/repository/__init__.py | 1 + .../repository/audit_event_repository.py | 12 ++ .../scheduling/ai_task_planner_service.py | 6 + .../scheduling/task_application_service.py | 4 + application/workflows/co4e_run_history.py | 99 +++++++++++++ .../workflows/co4e_workflow_service.py | 115 ++++++++------- .../workspaces/file_preview_helpers.py | 8 ++ .../workspaces/file_workspace_service.py | 3 + config.py | 10 ++ core/accounts.py | 9 ++ core/admin_agents.py | 13 ++ core/agent_command.py | 7 + core/agent_roles.py | 2 + core/agent_security.py | 11 ++ core/agent_security_types.py | 2 + core/ai_task_planner.py | 4 + core/appcontainer_sandbox.py | 1 + core/chat_agent.py | 12 ++ core/co4e.py | 57 ++++++++ core/co4e_builtins.py | 1 + core/co4e_run_manager.py | 39 ++++++ core/co4e_runner.py | 10 ++ core/code_agent.py | 5 + core/codebase_memory.py | 17 +++ core/codebase_memory_ui.py | 16 +++ core/context_budget.py | 16 +++ core/cron.py | 15 ++ core/custom_agents.py | 25 ++++ core/custom_icons.py | 4 + core/d3_graph.py | 1 + core/deps.py | 13 ++ core/doc_extract.py | 21 +++ core/doc_style_extract.py | 4 + core/ext_connectors.py | 11 ++ core/flows.py | 24 ++++ core/graph_server.py | 18 +++ core/groups.py | 7 + core/history.py | 17 +++ core/holiday_calendar.py | 5 + core/image_gen.py | 4 + core/jira_tool.py | 4 + core/link_fetch.py | 7 + core/mcp_client.py | 18 +++ core/model_pricing.py | 14 ++ core/ms365_auth.py | 10 ++ core/ms365_graph.py | 24 ++++ core/ms365_local.py | 8 ++ core/ms365_tools.py | 6 + core/permissions.py | 13 ++ core/pptx_edit.py | 10 ++ core/projects.py | 7 + core/routing/classifier.py | 5 + core/routing/clients.py | 6 + core/routing/models.py | 1 + core/routing/orchestrator.py | 3 + core/routing/prober.py | 12 ++ core/routing/scheduler.py | 10 ++ core/routing/selector.py | 7 + core/routing/service.py | 18 +++ core/routing/store.py | 7 + core/routing/switch_controller.py | 3 + core/sandbox_manager.py | 1 + core/skills.py | 15 ++ core/structure_graph.py | 30 ++++ core/task_excel.py | 2 + core/task_executors.py | 13 ++ core/task_import.py | 9 ++ core/task_scheduler.py | 30 +++- core/tasks.py | 13 ++ core/teams.py | 12 ++ core/telemetry_shared.py | 5 + core/tls_trust.py | 7 + core/tools.py | 4 + core/usage_cost.py | 1 + core/usage_periods.py | 3 + core/usage_tracker.py | 5 + core/windows_sandbox_vm.py | 1 + core/worker.py | 24 ++++ core/xlsx_write.py | 1 + domain/agents/agent_event.py | 31 +++++ domain/agents/agent_event_codec.py | 10 ++ domain/security/tool_policy.py | 18 +++ domain/tasks/schedule_calculator.py | 5 + domain/tools/tool_registry.py | 8 ++ domain/workflows/__init__.py | 1 + domain/workflows/run_record.py | 22 +++ domain/workspaces/workspace_session.py | 3 + i18n.py | 7 + infrastructure/config/config_repository.py | 15 ++ infrastructure/config/config_sections.py | 128 +++++++++++++++++ .../config/json_config_repository.py | 131 +++++++----------- infrastructure/config/schema_migration.py | 5 + infrastructure/config/settings_facade.py | 38 +++++ infrastructure/filesystem/command_tools.py | 10 ++ .../filesystem/execution_workspace.py | 13 ++ infrastructure/filesystem/fetch_tools.py | 2 + infrastructure/filesystem/file_tools.py | 10 ++ infrastructure/filesystem/tool_context.py | 6 + infrastructure/mcp/mcp_source_manager.py | 6 + .../persistence/json/atomic_json_file.py | 9 ++ .../json/conversation_repository_impl.py | 18 +++ .../persistence/json/task_repository_impl.py | 11 ++ .../json/workspace_repository_impl.py | 9 ++ infrastructure/providers/provider_registry.py | 10 ++ infrastructure/qt/qt_scheduler_clock.py | 5 + .../sandbox/sandbox_capabilities.py | 24 ++++ infrastructure/secrets/__init__.py | 3 + infrastructure/secrets/keyring_adapter.py | 13 ++ infrastructure/telemetry/audit_logger.py | 10 ++ infrastructure/telemetry/usage_sink.py | 16 +++ mcp_servers/ms365_server.py | 10 ++ mcp_servers/project_context/foundation.py | 35 ++++- .../project_context/providers/change.py | 15 +- .../project_context/providers/issue.py | 15 +- .../project_context/providers/knowledge.py | 15 +- mcp_servers/project_context/registry.py | 1 + mcp_servers/project_context/runtime.py | 12 ++ mcp_servers/project_context/server.py | 13 ++ .../project_context/tools/change_context.py | 10 ++ .../project_context/tools/issue_context.py | 11 ++ .../project_context/tools/knowledge_search.py | 7 + paths.py | 1 + presentation/chat/__init__.py | 5 +- presentation/chat/attachment_picker.py | 5 + presentation/chat/audio_recorder_widget.py | 1 + presentation/chat/chat_agents.py | 8 ++ presentation/chat/chat_bubble_style.py | 13 ++ presentation/chat/chat_event_stream.py | 16 ++- presentation/chat/chat_history_widget.py | 42 ++++++ presentation/chat/chat_input_box.py | 74 ++++++++-- presentation/chat/chat_output_panel.py | 4 + presentation/chat/chat_panel.py | 12 ++ presentation/chat/chat_session_store.py | 26 ++++ presentation/chat/chat_turn_runner.py | 35 ++++- presentation/chat/composer_mime.py | 86 ++++++++++++ presentation/chat/composer_widget.py | 91 +++++------- presentation/co4e/agent_list_panel.py | 1 + presentation/co4e/canvas_geometry.py | 12 ++ presentation/co4e/canvas_interaction_mixin.py | 39 ++++++ presentation/co4e/canvas_items.py | 56 ++++++++ presentation/co4e/co4e_agents.py | 10 ++ presentation/co4e/co4e_canvas_widget.py | 67 +++++++++ presentation/co4e/co4e_chat.py | 27 ++++ presentation/co4e/co4e_chat_view.py | 15 ++ presentation/co4e/co4e_flow_tabs.py | 14 ++ presentation/co4e/co4e_layout.py | 5 + presentation/co4e/co4e_run_control_widget.py | 5 + presentation/co4e/co4e_runs.py | 30 ++++ presentation/co4e/co4e_sidebar.py | 19 +++ presentation/co4e/co4e_tab.py | 11 ++ presentation/co4e/co4e_workflow_crud.py | 32 +++++ .../co4e/node_property_actions_mixin.py | 9 ++ presentation/co4e/node_property_panel.py | 15 ++ presentation/co4e/palette_list.py | 4 + presentation/co4e/skills_list_panel.py | 5 + presentation/co4e/step_config_section.py | 12 ++ presentation/dashboard/dashboard_tab.py | 23 ++- presentation/dashboard/habits_widget.py | 17 +++ .../dashboard/token_usage_card_widget.py | 8 ++ presentation/dashboard/usage_chart_widget.py | 54 +++++++- presentation/folder/ai_edit_model_resolver.py | 28 ++++ presentation/folder/ai_edit_pipeline.py | 28 ++++ presentation/folder/ai_file_editor_dialog.py | 10 ++ presentation/folder/code_editor.py | 30 ++++ .../folder/document_preview_manager.py | 23 +++ presentation/folder/folder_tab.py | 9 ++ .../folder/office_document_renderer.py | 20 +++ presentation/folder/workspace_file_tree.py | 9 ++ presentation/graph/graph_export.py | 81 +++++++++++ presentation/graph/graph_messages_view.py | 11 ++ presentation/graph/graph_qa_widget.py | 23 +++ presentation/graph/graph_renderer.py | 120 ++++++++-------- presentation/graph/graph_scene_builder.py | 95 +++++++++++++ presentation/graph/graph_scene_items.py | 34 +++++ presentation/graph/structure_graph_view.py | 18 +++ presentation/monitoring/monitoring_tab.py | 32 +++++ presentation/monitoring/shared/__init__.py | 3 + .../monitoring/shared/agent_kind_visuals.py | 67 +++++++++ presentation/monitoring/shared/ai_filter.py | 9 ++ presentation/monitoring/shared/badges.py | 11 ++ .../monitoring/shared/event_detail_panel.py | 7 + presentation/monitoring/shared/event_table.py | 23 +++ .../monitoring/shared/filter_scaffold.py | 12 ++ presentation/monitoring/shared/formatters.py | 2 + .../monitoring/shared/open_settings.py | 5 + presentation/monitoring/tabs/__init__.py | 3 + .../monitoring/tabs/action_logs_tab.py | 5 + .../monitoring/tabs/agent_edit_dialog.py | 11 ++ .../monitoring/tabs/agent_status_tab.py | 4 + .../monitoring/tabs/agents_admin_tab.py | 102 +++++--------- presentation/monitoring/tabs/mcp_tab.py | 5 + presentation/monitoring/tabs/overview_tab.py | 37 +++++ presentation/monitoring/tabs/pricing_panel.py | 16 +++ presentation/monitoring/tabs/sandbox_tab.py | 7 + .../monitoring/tabs/security_events_tab.py | 9 ++ .../monitoring/tabs/security_settings_tab.py | 5 + .../monitoring/tabs/tools_admin_tab.py | 15 ++ .../scheduling/ai_task_creator_dialog.py | 16 +++ .../scheduling/ai_task_import_dialog.py | 9 ++ .../scheduling/calendar_view_widget.py | 33 +++++ .../scheduling/kanban_board_widget.py | 27 ++++ presentation/scheduling/run_history_dialog.py | 2 + presentation/scheduling/schedule_task_tab.py | 27 ++++ .../settings/general_settings_widget.py | 10 ++ .../settings/parameter_settings_widget.py | 10 ++ .../settings/provider_settings_widget.py | 43 ++++++ .../settings/routing_settings_widget.py | 6 + presentation/shell/bootstrap.py | 4 + presentation/shell/lifecycle_coordinator.py | 10 ++ presentation/shell/main_window.py | 24 ++++ presentation/shell/nav_rail.py | 2 + presentation/shell/page_registry.py | 32 ++++- presentation/shell/rail_metrics.py | 5 + presentation/shell/rail_project.py | 7 + presentation/shell/session_events.py | 5 + presentation/shell/toast.py | 2 + presentation/shell/top_bar.py | 10 ++ presentation/shell/tray_manager.py | 5 + providers/anthropic.py | 28 ++++ providers/base.py | 42 ++++++ providers/openai_compat.py | 39 ++++++ security/attachment_validator.py | 1 + security/audit_logger.py | 12 ++ security/command_risk_classifier.py | 32 +++++ state.py | 9 ++ tests/integration/test_folder_tab.py | 20 +++ ui/accounts_tab.py | 96 +++++++++++++ ui/agent_manager_tab.py | 27 ++++ ui/co4e_agent_dialog.py | 17 +++ ui/co4e_tab.py | 42 +++++- ui/composer.py | 14 +- ui/connectors_panel.py | 37 +++++ ui/cowork_tab.py | 43 ++++++ ui/ext_connector_dialog.py | 15 ++ ui/file_edit_dialog.py | 16 +++ ui/flow_dialog.py | 71 ++++++++++ ui/help_agent_widget.py | 41 ++++++ ui/icons.py | 6 + ui/icons_admin_tab.py | 13 ++ ui/libreoffice_view.py | 39 ++++++ ui/login_dialog.py | 45 ++++++ ui/mcp_servers_dialog.py | 13 ++ ui/osutil.py | 1 + ui/permission_dialog.py | 8 ++ ui/routing_toggle.py | 18 +++ ui/settings_dialog.py | 15 ++ ui/sidebar.py | 24 ++++ ui/skill_manager_tab.py | 32 +++++ ui/skills_dialog.py | 36 +++++ ui/spline_chart.py | 10 ++ ui/task_editor_dialog.py | 22 +++ ui/terminal_panel.py | 35 +++++ ui/workspace_tab.py | 76 +++++++++- 264 files changed, 4593 insertions(+), 359 deletions(-) create mode 100644 application/workflows/co4e_run_history.py create mode 100644 infrastructure/config/config_sections.py create mode 100644 presentation/chat/composer_mime.py create mode 100644 presentation/graph/graph_export.py create mode 100644 presentation/graph/graph_scene_builder.py create mode 100644 presentation/monitoring/shared/agent_kind_visuals.py diff --git a/__main__.py b/__main__.py index 4c2c993..0f70253 100644 --- a/__main__.py +++ b/__main__.py @@ -17,6 +17,13 @@ def main() -> int: # a plain script (`python __main__.py`), `__package__` is empty so the # relative import fails — in that case put the package root (the parent # of this file's directory) on sys.path and use an absolute import. + """Điểm vào ``python -m cowork_local``. + + Import muộn để công cụ kiểu ``-h`` và test nạp được gói mà không phải dựng cả + ứng dụng Qt. Chạy như script thường (``python __main__.py``) thì + ``__package__`` rỗng nên import tương đối hỏng — lúc đó đưa thư mục cha vào + ``sys.path`` và dùng import tuyệt đối. + """ if __package__: from .app import run else: diff --git a/app.py b/app.py index 2249ee5..1db3034 100644 --- a/app.py +++ b/app.py @@ -61,6 +61,12 @@ def _set_windows_app_id() -> None: def run(argv: List[str] | None = None) -> int: + """Điểm vào ứng dụng: dựng Composition Root, gieo dữ liệu mặc định, áp theme + rồi mở cửa sổ chính. + + Mọi bước gieo (skill dựng sẵn, flow dựng sẵn) đều bọc trong ``try`` — việc + dọn nhà không bao giờ được phép chặn app khởi động. + """ argv = argv if argv is not None else sys.argv _set_windows_app_id() app = QApplication.instance() or QApplication(argv) @@ -115,6 +121,7 @@ def run(argv: List[str] | None = None) -> int: win = MainWindow(ctx, user_name="local") def _reapply_system_theme(*_a): + """Theme đang để "Theo hệ thống" thì áp lại mỗi khi Windows đổi sáng/tối.""" if ctx.config.theme == "system": set_active_theme("system") app.setStyleSheet(stylesheet("system")) diff --git a/application/conversations/conversation_application_service.py b/application/conversations/conversation_application_service.py index fdc9046..400e441 100644 --- a/application/conversations/conversation_application_service.py +++ b/application/conversations/conversation_application_service.py @@ -75,6 +75,12 @@ class ConversationApplicationService: permission_request: Optional[PermissionRequest] = None, attachment_reader: Optional[AttachmentReader] = None, ) -> None: + """Nhận vào các cổng (port) thay vì tự dựng phụ thuộc. + + ``model`` và ``tools`` bắt buộc; mọi thứ còn lại là tuỳ chọn và để None thì + bỏ qua bước đó. Nhờ vậy test dựng được service với đúng phần nó cần kiểm, + không phải dựng cả provider thật lẫn sandbox. + """ self._model = model self._tools = tools # Every hook is optional so the service degrades to a plain chat turn. diff --git a/application/conversations/core_runtime_adapter.py b/application/conversations/core_runtime_adapter.py index aabf714..0fea5ea 100644 --- a/application/conversations/core_runtime_adapter.py +++ b/application/conversations/core_runtime_adapter.py @@ -51,9 +51,11 @@ class CoreModelCall: """ def __init__(self, provider: Any) -> None: + """Bọc một provider của ``core/`` vào cổng ``ModelCallPort``.""" self._provider = provider def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None): + """Gọi model một lượt, có tự phục hồi khi tràn context hoặc bị giới hạn tốc độ.""" from ...core.code_agent import _call_provider_with_recovery return _call_provider_with_recovery(self._provider, messages, tools, on_text, @@ -66,6 +68,11 @@ class CoreToolRuntime: def __init__(self, output_dir: Path, *, title: str = "", extra_tools: Optional[Sequence[Any]] = None, extra_executor=None, security_config: Any = None, agent_role: str = "") -> None: + """Bọc bộ tool của ``core/`` vào cổng ``ToolRuntimePort``. + + Tên các tool phụ được gom sẵn vào một ``set`` ngay tại đây: mỗi lượt gọi tool + đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool. + """ self._output_dir = Path(output_dir) self._title = title self._extra_tools = list(extra_tools or ()) @@ -80,6 +87,7 @@ class CoreToolRuntime: # -- the configured extra tools, for the system-prompt hints ---------- # @property def extra_names(self) -> frozenset: + """Tên các tool bổ sung (MCP, connector) ngoài bộ dựng sẵn.""" return frozenset(self._extra_names) def _tool_context(self): @@ -206,6 +214,7 @@ class CoreToolRuntime: "plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]} def snapshot(self) -> Any: + """Ảnh chụp thư mục kết quả trước lượt chạy — dùng để biết tệp nào mới sinh ra.""" from ...core.tools import _snapshot return _snapshot(self._output_dir) @@ -294,16 +303,19 @@ def build_cowork_conversation_service( _apply_project_context(messages, project_context) def prompt_guard(messages: List[Dict[str, Any]]) -> None: + """Chốt an toàn cho prompt trước khi gửi: quét dấu hiệu tiêm lệnh.""" from ...core import agent_security agent_security.enforce_prompt(provider, messages, security_config, emit) def command_guard(name: str, args: Dict[str, Any]) -> None: + """Chốt an toàn cho lệnh shell trước khi chạy: phân loại rủi ro và chặn/hỏi.""" from ...core import agent_security agent_security.enforce_command(provider, name, args, security_config, emit) def compact(messages: List[Dict[str, Any]], cancel) -> None: + """Nén lịch sử hội thoại khi gần đầy cửa sổ ngữ cảnh.""" from ...core import context_budget context_budget.maybe_compact(provider, messages, security_config, diff --git a/application/conversations/tool_policy_gateway.py b/application/conversations/tool_policy_gateway.py index 7d60ffd..6c66295 100644 --- a/application/conversations/tool_policy_gateway.py +++ b/application/conversations/tool_policy_gateway.py @@ -39,7 +39,10 @@ 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: ... + """Hỏi người dùng; trả về ``True`` nếu được đồng ý.""" + def request(self, payload: Dict[str, Any]) -> bool: + """Hỏi người dùng về một lời gọi tool; trả về ``True`` nếu được đồng ý.""" + ... class ToolPolicyGateway: @@ -54,6 +57,11 @@ class ToolPolicyGateway: """ def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None: + """Nhận sổ đăng ký tool và tập năng lực cần xin phép. + + Truyền vào chứ không viết cứng: mỗi bề mặt chat có ngưỡng riêng, và test đặt + được ngưỡng của mình mà không đụng cấu hình thật. + """ self._registry = registry self._gated_capabilities = gated_capabilities diff --git a/application/model_routing/core_routing_adapter.py b/application/model_routing/core_routing_adapter.py index f2fdfa8..4e4afd9 100644 --- a/application/model_routing/core_routing_adapter.py +++ b/application/model_routing/core_routing_adapter.py @@ -33,6 +33,7 @@ class CoreRoutingEngine: """ def __init__(self, routing_service: Any) -> None: + """Bọc ``core/routing/service.py`` vào cổng quyết định định tuyến.""" self._routing_service = routing_service def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation: @@ -126,6 +127,9 @@ class AppContextModeResolver: """ def __init__(self, ctx: Any) -> None: + """Đọc chế độ định tuyến từ ``AppContext``, để tầng application không phải biết + hình dạng của context. + """ self._ctx = ctx def mode_for(self, surface: str) -> RoutingMode: diff --git a/application/model_routing/routing_application_service.py b/application/model_routing/routing_application_service.py index 9faf703..6db95a6 100644 --- a/application/model_routing/routing_application_service.py +++ b/application/model_routing/routing_application_service.py @@ -78,6 +78,10 @@ class RoutingApplicationService: *, confirm_timeout_sec: Optional[Callable[[], float]] = None, ) -> None: + """``mode_resolver`` để None thì mọi bề mặt đều coi như đang ở chế độ mặc định. + ``confirm_timeout_sec`` là hàm chứ không phải số: người dùng đổi thiết lập + giữa chừng thì lần hỏi sau phải theo giá trị mới. + """ self._decision_port = decision_port self._mode_resolver = mode_resolver # A callable rather than a number: the timeout lives in mutable config diff --git a/application/monitoring/dashboard_query_service.py b/application/monitoring/dashboard_query_service.py index 4a5194d..9de2971 100644 --- a/application/monitoring/dashboard_query_service.py +++ b/application/monitoring/dashboard_query_service.py @@ -32,6 +32,9 @@ class DashboardQueryService: """ def __init__(self, ctx: Any, directory: Optional[Path] = None) -> None: + """``directory`` để None thì đọc thư mục telemetry mặc định; test trỏ nó vào + ``tmp_path`` để không chạm dữ liệu thật. + """ self.ctx = ctx self._directory = directory @@ -94,16 +97,21 @@ class DashboardQueryService: return ut.period_totals(events, granularity, self.pricing(), offset) def period_range_label(self, granularity: str, offset: int) -> str: + """Nhãn hiển thị của một kỳ (tuần/tháng/năm cộng độ lệch).""" from cowork_local.core import usage_tracker as ut return ut.period_range_label(granularity, offset) def budget_status(self): + """Tình trạng ngân sách: đã dùng bao nhiêu, còn lại bao nhiêu, có vượt ngưỡng chưa.""" 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: + """Đặt hạn mức ngân sách mới — mở một chu kỳ đếm mới, chi tiêu trước đó không + còn được tính vào. + """ from cowork_local.core import usage_tracker as ut ut.set_budget(self.ctx.config, amount, currency) diff --git a/application/monitoring/dto/__init__.py b/application/monitoring/dto/__init__.py index e69de29..d81abbf 100644 --- a/application/monitoring/dto/__init__.py +++ b/application/monitoring/dto/__init__.py @@ -0,0 +1,3 @@ +"""DTO của phân hệ Giám sát: hình dạng dữ liệu mà tầng application trả cho +giao diện, không phụ thuộc nguồn đọc. +""" diff --git a/application/monitoring/dto/audit_event_dto.py b/application/monitoring/dto/audit_event_dto.py index 31ba1f3..8bd6dca 100644 --- a/application/monitoring/dto/audit_event_dto.py +++ b/application/monitoring/dto/audit_event_dto.py @@ -12,6 +12,9 @@ from typing import Any, Dict @dataclass(frozen=True) class AuditEventDTO: + """Một sự kiện kiểm toán ở dạng tầng application dùng — không phụ thuộc khuôn + lưu trên đĩa, nên đổi định dạng nhật ký không kéo theo sửa giao diện. + """ ts: str kind: str name: str @@ -40,6 +43,7 @@ class AuditEventDTO: ) def to_dict(self) -> Dict[str, Any]: + """Bản ghi dưới dạng dict cho lớp giao diện.""" return { "ts": self.ts, "kind": self.kind, "agent_role": self.agent_role, "name": self.name, "ok": self.ok, "detail": self.detail, diff --git a/application/monitoring/monitoring_query_service.py b/application/monitoring/monitoring_query_service.py index 0024f7e..1a35451 100644 --- a/application/monitoring/monitoring_query_service.py +++ b/application/monitoring/monitoring_query_service.py @@ -16,6 +16,7 @@ from .repository.audit_event_repository import AuditEventRepository @dataclass(frozen=True) class Page: + """Một trang kết quả truy vấn nhật ký: các mục, tổng số, số trang và cỡ trang.""" items: List[AuditEventDTO] total: int page: int @@ -23,6 +24,7 @@ class Page: @property def has_more(self) -> bool: + """Còn trang sau nữa không.""" return self.page * self.page_size < self.total @@ -31,11 +33,15 @@ class MonitoringQueryService: audit log; this service never writes anything.""" def __init__(self, repository: AuditEventRepository) -> None: + """Nhận kho sự kiện kiểm toán qua tham số — bản thật đọc đĩa, bản test nằm + trong bộ nhớ. + """ self._repository = repository def query(self, kind: Optional[str] = None, ok: Optional[bool] = None, text: Optional[str] = None, sort_by: str = "ts", descending: bool = True, page: int = 1, page_size: int = 50) -> Page: + """Lọc theo loại/kết quả/từ khoá, sắp xếp rồi cắt thành một trang.""" events = self._repository.load(kind=kind) if ok is not None: diff --git a/application/monitoring/repository/__init__.py b/application/monitoring/repository/__init__.py index e69de29..099e195 100644 --- a/application/monitoring/repository/__init__.py +++ b/application/monitoring/repository/__init__.py @@ -0,0 +1 @@ +"""Cổng đọc dữ liệu của phân hệ Giám sát — hợp đồng, không phải cài đặt.""" diff --git a/application/monitoring/repository/audit_event_repository.py b/application/monitoring/repository/audit_event_repository.py index a08492c..81fe1d7 100644 --- a/application/monitoring/repository/audit_event_repository.py +++ b/application/monitoring/repository/audit_event_repository.py @@ -13,7 +13,13 @@ from ..dto.audit_event_dto import AuditEventDTO class AuditEventRepository(Protocol): + """Cổng đọc nhật ký kiểm toán mà tầng application dùng. + + Chỉ là hợp đồng: bản cài đặt thật đọc từ file cục bộ hoặc thư mục chia sẻ, + còn test truyền vào bộ giả. + """ def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Đọc sự kiện kiểm toán, lọc theo loại nếu có.""" ... @@ -22,9 +28,11 @@ class CanonicalAuditEventRepository: — the only place this application service reaches into infrastructure.""" def __init__(self, audit_logger) -> None: + """Bọc bộ ghi nhật ký kiểm toán chuẩn để đọc sự kiện ra.""" self._audit_logger = audit_logger def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Đọc sự kiện từ nhật ký và đổi sang DTO của tầng application.""" events = self._audit_logger.load_events(kind=kind) return [AuditEventDTO.from_raw(e.to_dict()) for e in events] @@ -33,9 +41,13 @@ class InMemoryAuditEventRepository: """Test double — holds a fixed list of events, no file I/O.""" def __init__(self, events: List[AuditEventDTO]) -> None: + """Nhận sẵn danh sách sự kiện. Chép lại chứ không giữ tham chiếu: bên gọi sửa + danh sách gốc thì kết quả test không được đổi theo. + """ self._events = list(events) def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Trả về danh sách đã nạp sẵn, lọc theo loại nếu có.""" if kind is None: return list(self._events) return [e for e in self._events if e.kind == kind] diff --git a/application/scheduling/ai_task_planner_service.py b/application/scheduling/ai_task_planner_service.py index 6fbf15e..4371f7d 100644 --- a/application/scheduling/ai_task_planner_service.py +++ b/application/scheduling/ai_task_planner_service.py @@ -43,6 +43,9 @@ class AiTaskPlannerService: """ def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None: + """``provider_factory`` là hàm dựng provider, gọi lúc cần chứ không dựng sẵn — + provider có thể bị đổi giữa hai lần lập kế hoạch. + """ self._provider_factory = provider_factory def plan( @@ -86,6 +89,9 @@ class AiTaskPlannerService: return import_tasks(path) def _resolve_provider(self) -> Any: + """Provider dùng để lập kế hoạch; chưa cấu hình thì báo lỗi rõ ràng ngay tại + đây thay vì để lỗi nổ ra ở tận tầng HTTP. + """ if self._provider_factory is None: raise RuntimeError("No provider available to plan tasks.") return self._provider_factory() diff --git a/application/scheduling/task_application_service.py b/application/scheduling/task_application_service.py index d96c28d..cd62a85 100644 --- a/application/scheduling/task_application_service.py +++ b/application/scheduling/task_application_service.py @@ -75,6 +75,9 @@ class TaskApplicationService: """ def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None: + """``run_now`` để None thì service chỉ đọc/ghi task, không chạy được cái nào — + đúng cho ngữ cảnh không có scheduler (test, hay màn chỉ xem). + """ self._repository = repository self._run_now = run_now @@ -118,6 +121,7 @@ class TaskApplicationService: return task def delete(self, task_id: str) -> bool: + """Xoá một task; trả về ``False`` nếu id không tồn tại.""" if self._repository.get(task_id) is None: return False self._repository.delete(task_id) diff --git a/application/workflows/co4e_run_history.py b/application/workflows/co4e_run_history.py new file mode 100644 index 0000000..606b036 --- /dev/null +++ b/application/workflows/co4e_run_history.py @@ -0,0 +1,99 @@ +"""Đọc/ghi file lịch sử run của Co4E — tách khỏi ``co4e_workflow_service.py``. + +``Co4EWorkflowService`` lo vòng đời các run đang chạy; chỗ này lo đúng một +việc: đưa ``RunRecord`` ra đĩa và lấy lại được. Tách ra vì hành vi đọc/ghi ở +đây có những ràng buộc rất riêng — được ghi lại nguyên vẹn bên dưới — mà trộn +lẫn vào file điều phối thì không ai đọc tới. + +DTO ở ``domain/workflows/run_record.py`` không được chạm đĩa, nên việc này +nằm ở tầng application chứ không nằm trong domain. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, List, Tuple + +from ...domain.workflows.run_record import RunRecord +from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile + +#: Giữ N run gần nhất trên đĩa. Lịch sử chỉ để người dùng nhìn lại, không +#: phải sổ kiểm toán — để nó lớn vô hạn thì mỗi lần lưu lại phải tuần tự hoá +#: cả file, và lần lưu ấy nằm ngay trên đường đi của mọi sự kiện tiến độ. +HISTORY_CAP = 500 + + +class RunHistoryStore: + """Một file JSON chứa lịch sử run, kèm hai quy ước phải giữ nguyên. + + **Không cách ly file hỏng.** Bản đầu dùng ``AtomicJsonFile.read()``, nhưng + review thấy nó đổi hành vi thật so với ``Co4ERunManager`` cũ: gặp JSON + hỏng, ``AtomicJsonFile.read()`` ĐỔI TÊN file thành ``.bad-`` rồi + mới trả về mặc định, trong khi bản cũ chỉ bắt lỗi và ĐỂ NGUYÊN file tại + chỗ. Đó là thay đổi quan sát được trên đĩa mà không test nào khoá lại và + không có chú thích báo trước — Lâm (N3) quyết ngày 24/08: giữ hành vi cũ. + Vì thế :meth:`load` đọc thủ công bằng ``json.loads``. + + **Ghi hỏng không được làm vỡ luồng gọi.** :meth:`save` nuốt ``OSError``, + đúng như ``core/co4e_run_manager.py::_save_history``. Nó nằm trên đường đi + của mọi hook tiến độ (``_on_event``/``_on_finished``/``_on_failed``); để + lỗi ghi đĩa (đầy đĩa, mất quyền) ném ra là vỡ cả lượt xử lý sự kiện đang + chạy, chỉ vì lịch sử lần này không lưu được. Người dùng vẫn thấy Flow + Status đúng trong phiên hiện tại, chỉ là bản ghi trên đĩa lùi một bước. + + Ghi thì vẫn qua ``AtomicJsonFile``: bản tự viết bằng tmp + ``replace`` + thiếu ``fsync`` (dữ liệu có thể còn trong bộ đệm khi mất điện) và + ``Path.replace`` thỉnh thoảng bị Defender từ chối trên Windows. + """ + + def __init__(self, path: Path): + """Trỏ vào một file JSON. Chưa tồn tại cũng không sao — :meth:`load` coi như + lịch sử rỗng và :meth:`save` tự tạo thư mục cha. + """ + self.path = Path(path) + + def load(self) -> Tuple[Dict[str, RunRecord], int]: + """Đọc lịch sử; trả về ``({id: RunRecord}, số thứ tự lớn nhất đã dùng)``. + + Số thứ tự trả kèm để bên gọi sinh id tiếp theo không đụng vào id đã có + trong lịch sử — không có nó thì sau mỗi lần khởi động lại, ``run1`` + mới sẽ ghi đè ``run1`` cũ. + + File không có, không đọc được, hay JSON hỏng đều trả về rỗng: mất lịch + sử là chuyện chấp nhận được, chặn ứng dụng khởi động thì không. Từng + bản ghi hỏng cũng bị bỏ riêng lẻ, để một dòng lỗi không kéo theo cả + file. + """ + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {}, 0 + + runs: Dict[str, RunRecord] = {} + max_seq = 0 + for rec in data.get("runs", []): + try: + record = RunRecord.from_dict(rec) + except Exception: + continue + if not record.id: + continue + runs[record.id] = record + if record.id.startswith("run") and record.id[3:].isdigit(): + max_seq = max(max_seq, int(record.id[3:])) + return runs, max_seq + + def save(self, runs: List[RunRecord]) -> None: + """Ghi ``HISTORY_CAP`` run gần nhất xuống đĩa, ghi nguyên tử. + + Lỗi ghi bị nuốt có chủ ý — xem docstring của lớp. + """ + payload = {"runs": [r.to_dict() for r in runs[-HISTORY_CAP:]]} + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + AtomicJsonFile(self.path).write(payload) + except OSError: + pass + + +__all__ = ["RunHistoryStore", "HISTORY_CAP"] diff --git a/application/workflows/co4e_workflow_service.py b/application/workflows/co4e_workflow_service.py index 3b2d3cb..d2b0c70 100644 --- a/application/workflows/co4e_workflow_service.py +++ b/application/workflows/co4e_workflow_service.py @@ -32,12 +32,20 @@ Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của wi KHÔNG xoá/sửa ``core/co4e_run_manager.py`` — lớp cũ tiếp tục chạy song song cho tới khi widget Co4E Studio thật (``ui/co4e_tab.py``) chuyển hẳn sang dùng service này. + +SEAM · dựng 2026-08-25 · chưa nối dây (F-05) +------------------------------------------------------------ +Được nối khi: ``ui/co4e_tab.py`` bỏ ``Co4ERunManager`` và nhận service này qua ``build_co4e_tab(ctx, workflow_service)``. +Để dormant thì sao: Hai bản cùng giữ vòng đời run đang chạy song song. Càng +để lâu thì sửa một lỗi lại phải sửa hai nơi — và đến một lúc sẽ có người +quên nơi thứ hai. + +Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên +và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng +đọc theo — đừng sửa ngày để làm im lời nhắc. """ from __future__ import annotations -from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile - -import json import os from datetime import datetime from pathlib import Path @@ -45,12 +53,13 @@ from typing import Callable, Dict, List, Optional, Protocol, Set from ...core.co4e import CO4E_DIR, STEP_DONE, STEP_ERROR, STEP_PLANNED, Workflow, slugify, workflow_to_dict from ...domain.workflows.run_record import RunRecord +from .co4e_run_history import RunHistoryStore _TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED} -_HISTORY_CAP = 500 # giữ N run gần nhất trên đĩa def _now_str() -> str: + """Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' — đúng định dạng lịch sử run đang lưu.""" return datetime.now().strftime("%Y-%m-%d %H:%M") @@ -70,15 +79,24 @@ class RunnerJob(Protocol): đồng bộ trong test. """ - def emit_event(self, ev: dict) -> None: ... - def is_cancelled(self) -> bool: ... + def emit_event(self, ev: dict) -> None: + """Đẩy một sự kiện tiến độ từ luồng nền về service.""" + ... + + def is_cancelled(self) -> bool: + """``True`` khi người dùng đã bấm dừng — thân job phải tự kiểm để thoát sớm.""" + ... class RunWorkerHandle(Protocol): """Điều khiển một job đang chạy nền — tương ứng phần ``AgentWorker.request_stop()`` cũ mà ``Co4ERunManager.stop()`` gọi.""" - def request_stop(self) -> None: ... + def request_stop(self) -> None: + """Xin dừng run. Chỉ là yêu cầu: job đang chạy phải tự thấy qua + ``is_cancelled()`` rồi thoát, không ai giết luồng giữa chừng. + """ + ... class WorkflowRunner(Protocol): @@ -94,7 +112,9 @@ class WorkflowRunner(Protocol): def start(self, run_id: str, job: Callable[[RunnerJob], Optional[dict]], on_event: Callable[[dict], None], on_finished: Callable[[Optional[dict]], None], - on_failed: Callable[[str], None]) -> RunWorkerHandle: ... + on_failed: Callable[[str], None]) -> RunWorkerHandle: + """Chạy ``job`` và trả về tay cầm để dừng nó.""" + ... class Co4EWorkflowService: @@ -108,6 +128,12 @@ class Co4EWorkflowService: def __init__(self, ctx, *, history_path: Optional[Path] = None, runner: Optional[WorkflowRunner] = None): + """Dựng service. + + ``runner`` để None nghĩa là chưa có ai chạy được run — đúng trạng thái hiện + nay, vì adapter Qt thật thuộc về tầng ``presentation/`` và chưa được nối. + Test tiêm runner chạy đồng bộ vào đây. + """ self.ctx = ctx self._runs: Dict[str, RunRecord] = {} self._worker_handles: Dict[str, RunWorkerHandle] = {} @@ -116,10 +142,12 @@ class Co4EWorkflowService: self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no self._runner = runner # DTO domain khong duoc cham dia (xem domain/workflows/run_record.py), - # nen viec doc/ghi file lich su nam o day, tang application. + # nen viec doc/ghi file lich su nam o tang application — cu the la + # co4e_run_history.py::RunHistoryStore. self._history_path_value = ( Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json") ) + self._history = RunHistoryStore(self._history_path_value) self._changed_callbacks: List[Callable[[], None]] = [] self._event_callbacks: List[Callable[[str, dict], None]] = [] self._load_history() # khoi phuc lich su cu de Flow Status @@ -127,71 +155,42 @@ class Co4EWorkflowService: # ---- callback thay Signal --------------------------------------------- def on_changed(self, cb: Callable[[], None]) -> None: + """Đăng ký callback gọi mỗi khi danh sách run đổi — thay cho signal Qt cũ.""" self._changed_callbacks.append(cb) def on_event(self, cb: Callable[[str, dict], None]) -> None: + """Đăng ký callback nhận sự kiện tiến độ của từng run — thay cho signal Qt cũ.""" self._event_callbacks.append(cb) def _emit_changed(self) -> None: + """Lưu lịch sử rồi báo mọi người đăng ký.""" self._save_history() # xem docstring dau file: giu dung thu tu ban Qt cu for cb in self._changed_callbacks: cb() def _emit_event(self, run_id: str, ev) -> None: + """Chuyển một sự kiện tiến độ tới mọi callback đã đăng ký.""" for cb in self._event_callbacks: cb(run_id, ev) # ---- persistence -------------------------------------------------- - # Doc/ghi thu cong (json.loads/write_text + tmp.replace), KHONG dung - # AtomicJsonFile — ban dau file nay dung AtomicJsonFile.read(), nhung - # review phat hien no doi hanh vi that so voi Co4ERunManager cu: gap - # JSON hong, AtomicJsonFile.read() ĐOI TEN file hong thanh - # ".bad-" (quarantine) roi moi tra ve mac dinh, trong - # khi ban cu chi bat loi va ĐE NGUYEN file hong tai cho, khong dong gi - # vao no. Day la mot thay doi quan sat duoc tren dia ma khong test nao - # khoa lai va khong co comment bao truoc — Lam (N3) da quyet 24/08: - # GIU HANH VI CU nguyen van (khong quarantine), vi day la buoc tach - # chi duoc phep doi hanh vi khi da noi ra ro rang va co lưới an toan, - # khong phai luc nay. def _load_history(self) -> None: - try: - data = json.loads(self._history_path_value.read_text(encoding="utf-8")) - except (OSError, ValueError): - return - max_seq = 0 - for rec in data.get("runs", []): - try: - record = RunRecord.from_dict(rec) - except Exception: - continue - if not record.id: - continue - self._runs[record.id] = record - if record.id.startswith("run") and record.id[3:].isdigit(): - max_seq = max(max_seq, int(record.id[3:])) - self._seq = max_seq # tranh sinh id trung voi lich su + """Khôi phục lịch sử run từ đĩa lúc khởi động. + + Lấy luôn số thứ tự lớn nhất đã dùng để ``_next_id()`` không sinh trùng + id với run cũ. + """ + self._runs, self._seq = self._history.load() def _save_history(self) -> None: - runs = list(self._runs.values())[-_HISTORY_CAP:] - payload = {"runs": [r.to_dict() for r in runs]} - try: - self._history_path_value.parent.mkdir(parents=True, exist_ok=True) - # AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync - # (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng - # Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows. - AtomicJsonFile(self._history_path_value).write(payload) - except OSError: - # Giu dung hanh vi cu (core/co4e_run_manager.py::_save_history): - # mot lan luu that bai (day dia, mat quyen...) KHONG duoc phep - # chan luong goi cua moi hook (_on_event/_on_finished/_on_failed) - # dang di qua _emit_changed(). Bo try/except nay se lam mot loi - # ghi dia lam vo ca luot xu ly su kien dang chay, chi vi lich su - # khong luu duoc lan nay -- nguoi dung van thay Flow Status dung - # trong phien hien tai, chi la ban ghi tren dia lui lai mot buoc. - pass + """Ghi lịch sử xuống đĩa. Lỗi ghi bị nuốt có chủ ý — xem + ``co4e_run_history.py::RunHistoryStore``. + """ + self._history.save(list(self._runs.values())) # ---- lifecycle ---------------------------------------------------- def _next_id(self) -> str: + """Sinh id run kế tiếp ('run1', 'run2', ...), không đụng id đã có trong lịch sử.""" self._seq += 1 return f"run{self._seq}" @@ -247,6 +246,7 @@ class Co4EWorkflowService: # ---- worker callbacks (goi tu runner, thay slot Qt cu) ----------------- def _on_event(self, run_id: str, ev) -> None: + """Nhận sự kiện từ job đang chạy và cập nhật bản ghi run.""" record = self._runs.get(run_id) if record is not None and isinstance(ev, dict): t = ev.get("type") @@ -271,6 +271,7 @@ class Co4EWorkflowService: self._emit_event(run_id, ev) def _on_finished(self, run_id: str) -> None: + """Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'.""" record = self._runs.get(run_id) if record is not None and record.status == "running": # job returned without a run_done event (shouldn't happen) — settle it @@ -278,6 +279,7 @@ class Co4EWorkflowService: self._emit_changed() def _on_failed(self, run_id: str, err: str) -> None: + """Job ném lỗi: ghi lỗi vào bản ghi và báo ra ngoài một sự kiện ``run_error``.""" record = self._runs.get(run_id) if record is not None: record.status = "error" @@ -287,6 +289,7 @@ class Co4EWorkflowService: # ---- control -------------------------------------------------------- def stop(self, run_id: str) -> None: + """Yêu cầu dừng một run đang chạy và đánh dấu 'stopped'.""" record = self._runs.get(run_id) worker = self._worker_handles.get(run_id) if record is not None and worker is not None and record.running: @@ -295,6 +298,7 @@ class Co4EWorkflowService: self._emit_changed() def stop_all(self) -> None: + """Dừng mọi run của workspace đang chọn (Flow Status vốn lọc theo project).""" # Only the CURRENT workspace's runs (Flow Status is per-project). for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]: self.stop(run_id) @@ -315,6 +319,7 @@ class Co4EWorkflowService: self._emit_changed() def remove(self, run_id: str) -> None: + """Xoá một run khỏi lịch sử; đang chạy thì dừng trước.""" record = self._runs.get(run_id) if record is not None and record.running: self.stop(run_id) @@ -323,6 +328,7 @@ class Co4EWorkflowService: self._emit_changed() def clear_finished(self) -> None: + """Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy.""" # Only clear finished runs of the CURRENT workspace. for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]: self._runs.pop(run_id, None) @@ -343,9 +349,11 @@ class Co4EWorkflowService: return list(self._runs.values()) def get(self, run_id: str) -> Optional[RunRecord]: + """Lấy một run theo id; ``None`` nếu không có.""" return self._runs.get(run_id) def active_count(self) -> int: + """Số run đang chạy của workspace đang chọn — dùng cho huy hiệu trên tab.""" return sum(1 for r in self._runs.values() if r.running and self._belongs(r)) def set_current_project(self, project_id: str) -> None: @@ -363,6 +371,7 @@ class Co4EWorkflowService: self._output_root = Path(root) if root else None def _out_dir(self, wf: Workflow) -> Path: + """Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có.""" # Flow deliverables are written into the SELECTED workspace (the active # project's folder) so they land where the user works with files (Folder # tab), not in the config/install folder. One subfolder per flow keeps diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py index 393395a..3f1c78f 100644 --- a/application/workspaces/file_preview_helpers.py +++ b/application/workspaces/file_preview_helpers.py @@ -28,6 +28,9 @@ def pptx_available() -> bool: def read_text(path: str) -> str: + """Đọc tệp dạng văn bản, thay ký tự hỏng thay vì ném lỗi; không đọc được thì + trả về chuỗi rỗng. + """ try: return Path(path).read_text(encoding="utf-8", errors="replace") except OSError as exc: @@ -35,6 +38,11 @@ def read_text(path: str) -> str: def is_probably_text(path: str) -> bool: + """Đoán tệp này có phải văn bản không, bằng cách tìm byte NUL trong phần đầu. + + Đoán sai theo hướng "là văn bản" sẽ hiện một màn hình ký tự rác, nên phép + thử cố tình bảo thủ. + """ try: with open(path, "rb") as f: chunk = f.read(4096) diff --git a/application/workspaces/file_workspace_service.py b/application/workspaces/file_workspace_service.py index 67ff35e..c4252dd 100644 --- a/application/workspaces/file_workspace_service.py +++ b/application/workspaces/file_workspace_service.py @@ -32,6 +32,9 @@ class FileWorkspaceService: """ def __init__(self, session) -> None: # WorkspaceSession - see module docstring + """Nhận một ``WorkspaceSession`` — mọi đường dẫn về sau đều bị nó chặn trong + phạm vi cho phép. + """ self._session = session def list_tree(self, rel: str = ".") -> Dict[str, Any]: diff --git a/config.py b/config.py index bfdacd4..df36bc3 100644 --- a/config.py +++ b/config.py @@ -275,6 +275,11 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]: + """Cho phép biến môi trường ghi đè cấu hình. + + Dùng khi chạy trong container/CI: đặt endpoint và khoá qua biến môi trường mà + không phải sửa file cấu hình. + """ data = copy.deepcopy(data) oc = data["providers"]["openai_compat"] if os.getenv("OPENAI_API_KEY"): @@ -361,6 +366,11 @@ class AppConfig(JsonConfigRepository): """ def __init__(self, data=None, path: Path = CONFIG_PATH, **kw): + """Mở cấu hình từ đĩa, hoặc dựng thẳng từ dict khi truyền ``data``. + + Dạng ``AppConfig(data=..., path=...)`` là để 13 file test dựng cấu hình mà + không chạm đĩa; giữ nguyên vì bỏ đi là phải sửa cả 13 file. + """ if data is None: super().__init__(Path(path), **kw) return diff --git a/core/accounts.py b/core/accounts.py index 9c8a212..378e1bf 100644 --- a/core/accounts.py +++ b/core/accounts.py @@ -35,6 +35,7 @@ _LAST_LOGIN_PATH = CONFIG_DIR / "last_login.json" def save_last_login(username: str, role: str) -> None: + """Nhớ tài khoản đăng nhập gần nhất để lần mở sau điền sẵn.""" try: _LAST_LOGIN_PATH.parent.mkdir(parents=True, exist_ok=True) _LAST_LOGIN_PATH.write_text( @@ -44,6 +45,7 @@ def save_last_login(username: str, role: str) -> None: def load_last_login() -> Optional[Tuple[str, str]]: + """Cặp (tên đăng nhập, vai trò) của lần đăng nhập gần nhất; ``None`` nếu chưa có.""" try: data = json.loads(_LAST_LOGIN_PATH.read_text(encoding="utf-8")) username, role = data.get("username", ""), data.get("role", "") @@ -61,6 +63,7 @@ CODE_LENGTH = 12 @dataclass class Account: + """Một tài khoản người dùng: tên đăng nhập, vai trò, tên hiển thị và nhóm.""" username: str role: str display_name: str = "" @@ -73,6 +76,7 @@ class Account: def accounts_dir(shared_dir: str) -> Path: + """Thư mục chứa tài khoản, nằm trong thư mục chia sẻ của đội.""" return Path(shared_dir).expanduser() / "accounts" @@ -93,6 +97,7 @@ def generate_code(existing_codes: Optional[Set[str]] = None) -> str: def save_account(account: Account, directory: Path) -> Path: + """Ghi một tài khoản ra ``.json`` (tên file đã được làm sạch).""" directory.mkdir(parents=True, exist_ok=True) path = directory / f"{_safe_username(account.username)}.json" path.write_text(json.dumps(asdict(account), ensure_ascii=False, indent=2), encoding="utf-8") @@ -100,6 +105,7 @@ def save_account(account: Account, directory: Path) -> Path: def load_account(username: str, directory: Path) -> Optional[Account]: + """Đọc một tài khoản theo tên đăng nhập; không có thì trả ``None``.""" path = directory / f"{_safe_username(username)}.json" if not path.exists(): return None @@ -112,6 +118,7 @@ def load_account(username: str, directory: Path) -> Optional[Account]: def list_accounts(directory: Path) -> List[Account]: + """Liệt kê mọi tài khoản trong thư mục; thư mục chưa có thì trả list rỗng.""" if not directory.exists(): return [] out: List[Account] = [] @@ -124,6 +131,7 @@ def list_accounts(directory: Path) -> List[Account]: def delete_account(username: str, directory: Path) -> bool: + """Xoá file tài khoản; trả về ``True`` nếu có file để xoá.""" path = directory / f"{_safe_username(username)}.json" try: path.unlink() @@ -133,6 +141,7 @@ def delete_account(username: str, directory: Path) -> bool: def find_by_username(username: str, directory: Path) -> Optional[Account]: + """Bí danh của :func:`load_account`, giữ cho mã cũ gọi theo tên này vẫn chạy.""" return load_account(username, directory) diff --git a/core/admin_agents.py b/core/admin_agents.py index 30c6d4f..092228f 100644 --- a/core/admin_agents.py +++ b/core/admin_agents.py @@ -74,6 +74,7 @@ _KIND_PROMPTS = { @dataclass class AdminAgent: + """Một agent chuyên trách do quản trị cấu hình: prompt riêng, provider và model riêng.""" agent_id: str name: str task_kind: str = "cowork" @@ -85,6 +86,9 @@ class AdminAgent: updated_by: str = "" def effective_prompt(self) -> str: + """Prompt hệ thống thật sự dùng: prompt mặc định theo loại việc, rồi tới phần + quản trị viết thêm. + """ parts = [_KIND_PROMPTS.get(self.task_kind, ""), (self.prompt or "").strip()] return "\n\n".join(p for p in parts if p) @@ -98,12 +102,17 @@ def agents_admin_dir(shared_dir: str = "") -> Path: def _slug(name: str) -> str: + """Định danh an toàn cho tên file, suy từ tên agent.""" s = re.sub(r"[^\w\-]+", "-", (name or "").strip().lower()).strip("-") return s or "agent" def new_agent(name: str, task_kind: str = "cowork", prompt: str = "", provider: str = "", model: str = "", updated_by: str = "") -> AdminAgent: + """Tạo một agent quản trị mới; loại việc lạ thì rơi về 'cowork'. + + Id ghép slug với 6 ký tự ngẫu nhiên để hai agent trùng tên không đè file nhau. + """ return AdminAgent( agent_id=f"{_slug(name)}-{uuid.uuid4().hex[:6]}", name=name.strip(), task_kind=task_kind if task_kind in TASK_KINDS else "cowork", @@ -113,6 +122,7 @@ def new_agent(name: str, task_kind: str = "cowork", prompt: str = "", def save_agent(agent: AdminAgent, directory: Path) -> Path: + """Ghi một agent ra ``.json``.""" directory.mkdir(parents=True, exist_ok=True) path = directory / f"{agent.agent_id}.json" path.write_text(json.dumps(asdict(agent), ensure_ascii=False, indent=2), encoding="utf-8") @@ -120,6 +130,7 @@ def save_agent(agent: AdminAgent, directory: Path) -> Path: def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]: + """Đọc một agent theo id; không có thì trả ``None``.""" path = directory / f"{agent_id}.json" if not path.exists(): return None @@ -132,6 +143,7 @@ def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]: def list_agents(directory: Path, enabled_only: bool = False) -> List[AdminAgent]: + """Liệt kê agent trong thư mục; ``enabled_only`` chỉ lấy agent đang bật.""" if not directory.exists(): return [] out: List[AdminAgent] = [] @@ -165,6 +177,7 @@ def ensure_help_agent(directory: Path) -> AdminAgent: def delete_agent(agent_id: str, directory: Path) -> bool: + """Xoá file agent; trả về ``True`` nếu có file để xoá.""" try: (directory / f"{agent_id}.json").unlink() return True diff --git a/core/agent_command.py b/core/agent_command.py index d3271b2..5da822f 100644 --- a/core/agent_command.py +++ b/core/agent_command.py @@ -31,6 +31,7 @@ _CMD = re.compile(r"(? str: + """Định danh an toàn suy từ tên agent (dùng chung hàm với Co4E).""" from .co4e import slugify return slugify(name) @@ -45,6 +46,11 @@ def collect_agents(shared_dir: str = "") -> List[dict]: seen: set[str] = set() def _add(slug: str, name: str, desc: str, persona: str, source: str) -> None: + """Thêm một agent vào danh sách gộp; bỏ qua nếu trùng slug hoặc thiếu persona. + + Agent không có persona thì không dùng được — thêm vào chỉ làm bảng gợi ý dài + ra mà chọn vào lại không chạy. + """ if not slug or slug in seen or not persona.strip(): return seen.add(slug) @@ -69,6 +75,7 @@ def collect_agents(shared_dir: str = "") -> List[dict]: def _persona_block(agent: dict) -> str: + """Khối prompt mô tả một agent, chèn vào đầu lượt chat khi người dùng gõ ``/agent:``.""" return f"## Agent: {agent['name']}\n{agent['persona']}" diff --git a/core/agent_roles.py b/core/agent_roles.py index 7f605b4..4e2d6c6 100644 --- a/core/agent_roles.py +++ b/core/agent_roles.py @@ -37,6 +37,7 @@ HELP = "help" class AgentRole(NamedTuple): + """Một vai trò agent: khoá, nhãn hiển thị và prompt mặc định.""" key: str label: str description: str @@ -61,5 +62,6 @@ ROLES: Dict[str, AgentRole] = { def label_for(role_key: str) -> str: + """Nhãn của một vai trò; khoá lạ thì trả về chính khoá, rỗng thì trả về "—".""" role = ROLES.get(role_key) return role.label if role else (role_key or "—") diff --git a/core/agent_security.py b/core/agent_security.py index 917c771..8877301 100644 --- a/core/agent_security.py +++ b/core/agent_security.py @@ -149,6 +149,10 @@ def _ai_verdict(provider: Provider, system_prompt: str, content: str, layer: str def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> SecurityVerdict: + """Nhờ model xét prompt người dùng theo bộ luật an toàn. + + Prompt rỗng thì cho qua ngay, khỏi tốn một lượt gọi. + """ if not (user_text or "").strip(): return SecurityVerdict(True, "", "prompt") system = _PROMPT_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -157,6 +161,7 @@ def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> Secu def validate_attachment(provider: Provider, filename: str, content: str, rules_text: str) -> SecurityVerdict: + """Nhờ model xét nội dung một tệp đính kèm theo bộ luật an toàn.""" if not (content or "").strip(): return SecurityVerdict(True, "", "attachment") system = _ATTACHMENT_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -165,6 +170,11 @@ def validate_attachment(provider: Provider, filename: str, content: str, def validate_command(provider: Provider, command: str, rules_text: str, ai_enabled: bool) -> SecurityVerdict: + """Nhờ model xét một lệnh shell theo bộ luật an toàn. + + ``ai_enabled=False`` thì cho qua — người dùng đã tắt lớp xét bằng AI, bộ luật + tĩnh vẫn chạy ở chỗ khác. + """ if not ai_enabled: return SecurityVerdict(True, "", "command") system = _COMMAND_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -173,6 +183,7 @@ def validate_command(provider: Provider, command: str, # ---- call-site convenience wrappers (used by chat_agent.py / code_agent.py) -- def _security_conf(config) -> dict: + """Nhóm cấu hình ``agent_security``; không có config thì trả dict rỗng.""" return (config.data.get("agent_security", {}) if config is not None else {}) diff --git a/core/agent_security_types.py b/core/agent_security_types.py index 044d01d..af28aab 100644 --- a/core/agent_security_types.py +++ b/core/agent_security_types.py @@ -19,6 +19,7 @@ from dataclasses import dataclass @dataclass class SecurityVerdict: + """Kết quả một lớp kiểm an toàn: cho qua hay không, lý do, và lớp nào ra phán quyết.""" allowed: bool reason: str = "" layer: str = "" # "prompt" | "attachment" | "command" @@ -29,5 +30,6 @@ class SecurityBlocked(RuntimeError): the admin alert; ``str(exc)`` is the short, user-facing reason.""" def __init__(self, verdict: SecurityVerdict): + """Lấy lý do trong phán quyết làm thông điệp; không có lý do thì ghi rõ lớp nào chặn.""" super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).") self.verdict = verdict diff --git a/core/ai_task_planner.py b/core/ai_task_planner.py index fe325b1..22bd0a6 100644 --- a/core/ai_task_planner.py +++ b/core/ai_task_planner.py @@ -54,6 +54,10 @@ def _extract_json(text: str) -> Optional[dict]: def _clamp(value, allowed, default): + """Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định. + + Cần vì model hay trả về giá trị gần đúng ('High' thay vì 'high'). + """ return value if value in allowed else default diff --git a/core/appcontainer_sandbox.py b/core/appcontainer_sandbox.py index b872867..3b14ba5 100644 --- a/core/appcontainer_sandbox.py +++ b/core/appcontainer_sandbox.py @@ -48,6 +48,7 @@ class AppContainerSandbox: display_name: str = "CoworkLocal Sandbox", description: str = "Isolated execution environment for Cowork Local agent", ): + """Đặt tên và mô tả cho hồ sơ AppContainer; chưa tạo gì trên máy.""" self.profile_name = profile_name self.display_name = display_name self.description = description diff --git a/core/chat_agent.py b/core/chat_agent.py index eaa0917..3b41cb9 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -135,6 +135,12 @@ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') def _safe_filename(name: str) -> str: + """Làm sạch tên tệp do model đề xuất: bỏ đường dẫn, thay ký tự cấm, không bao + giờ trả về chuỗi rỗng. + + Model hay trả về tên có dấu ``/`` hoặc ``..`` — ghi thẳng là thoát khỏi thư + mục làm việc. + """ base = Path(str(name)).name.strip() base = _UNSAFE.sub("_", base).strip(" _.") or "output.txt" if "." not in base: @@ -307,17 +313,23 @@ def run_chat( emit: EmitFn, cancel: Optional[CancelFn] = None, ) -> Dict[str, Any]: + """Chạy một lượt chat thuần (không có tool) và phát nội dung dần ra ngoài. + + Tự chèn prompt hệ thống nếu tin nhắn đầu chưa phải ``system``. + """ if not messages or messages[0].get("role") != "system": messages.insert(0, {"role": "system", "content": COWORK_SYSTEM_PROMPT}) # Rulebase: always attach security rules so the agent follows them every turn _apply_security_rules(messages, load_rules()) def on_text(piece: str) -> None: + """Đẩy từng mẩu câu trả lời ra ngoài.""" emit({"type": "text", "delta": piece}) def on_reasoning(piece: str) -> None: # Stream the model's reasoning so the UI can show a live, collapsible # "Thinking" box (and keep the indicator active). + """Đẩy từng mẩu suy luận nội bộ ra ngoài, để giao diện hiện hộp "Đang nghĩ".""" emit({"type": "reasoning", "delta": piece}) assistant = provider.chat(messages, tools=None, on_text=on_text, cancel=cancel, diff --git a/core/co4e.py b/core/co4e.py index 46e3682..ff64646 100644 --- a/core/co4e.py +++ b/core/co4e.py @@ -54,6 +54,9 @@ RUN_MODES = ("auto", "plan", "manual") def slugify(value: str) -> str: + """Chuyển một chuỗi thành slug an toàn cho tên file: chỉ chữ/số/gạch, gộp gạch + liên tiếp. Rỗng thì trả về 'step' để không bao giờ sinh ra tên file trống. + """ s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (value or "").strip().lower()) return "-".join(filter(None, s.split("-"))) or "step" @@ -88,11 +91,13 @@ class Step: @property def is_parallel(self) -> bool: + """Bước này có chạy nhiều sub-agent song song hay không.""" return self.variant == "parallel" @dataclass class Node: + """Một node trên khung vẽ: id, toạ độ, và bước (:class:`Step`) mà nó đại diện.""" id: str x: float = 0.0 y: float = 0.0 @@ -101,6 +106,7 @@ class Node: @dataclass class Edge: + """Một cạnh nối hai node, quy định thứ tự chạy giữa chúng.""" id: str source: str target: str @@ -108,6 +114,7 @@ class Edge: @dataclass class Workflow: + """Một luồng Co4E: danh sách node, cạnh, và cờ đánh dấu đây có phải mẫu không.""" id: str name: str = "Untitled flow" is_template: bool = False @@ -132,6 +139,10 @@ class CustomAgent: # ---- (de)serialization --------------------------------------------------- def step_from_dict(d: dict) -> Step: + """Dựng :class:`Step` từ dict đọc trên đĩa. + + Lọc bỏ khoá lạ để file luồng của phiên bản mới hơn không làm vỡ bản cũ. + """ d = dict(d or {}) subs = d.pop("sub_agents", None) or [] known = Step().__dict__.keys() @@ -145,11 +156,13 @@ def step_from_dict(d: dict) -> Step: def node_from_dict(d: dict) -> Node: + """Dựng :class:`Node` từ dict đọc trên đĩa.""" return Node(id=str(d.get("id", "")), x=float(d.get("x", 0) or 0), y=float(d.get("y", 0) or 0), data=step_from_dict(d.get("data", {}))) def workflow_from_dict(d: dict) -> Workflow: + """Dựng :class:`Workflow` từ dict đọc trên đĩa.""" return Workflow( id=str(d.get("id", "")), name=d.get("name", "Untitled flow"), @@ -161,6 +174,7 @@ def workflow_from_dict(d: dict) -> Workflow: def workflow_to_dict(wf: Workflow) -> dict: + """Chuyển một luồng thành dict để ghi JSON.""" return { "id": wf.id, "name": wf.name, "is_template": wf.is_template, "nodes": [{"id": n.id, "x": n.x, "y": n.y, "data": _step_dict(n.data)} for n in wf.nodes], @@ -169,16 +183,19 @@ def workflow_to_dict(wf: Workflow) -> dict: def _step_dict(step: Step) -> dict: + """Chuyển một bước thành dict; ``asdict`` đã tự chuyển ``sub_agents`` thành list dict.""" d = asdict(step) # asdict already turns sub_agents into list[dict] return d def agent_to_dict(a: CustomAgent) -> dict: + """Chuyển một agent tự tạo thành dict để ghi JSON.""" return asdict(a) def agent_from_dict(d: dict) -> CustomAgent: + """Dựng :class:`CustomAgent` từ dict, lọc bỏ khoá lạ.""" known = CustomAgent(id="").__dict__.keys() d = {k: v for k, v in (d or {}).items() if k in known} d.setdefault("id", "") @@ -193,32 +210,43 @@ _counter = {"n": 0} def _mint_id(prefix: str) -> str: + """Sinh id tăng dần dạng ``_000001``.""" _counter["n"] += 1 return f"{prefix}_{_counter['n']:06d}" def new_node_id() -> str: + """Id mới cho một node.""" return _mint_id("node") def new_edge_id(source: str, target: str) -> str: + """Id cạnh suy ra TỪ cặp nguồn/đích. + + Cố ý không ngẫu nhiên: nhờ vậy nối lại đúng cặp node đó luôn cho ra cùng + một id, và không thể sinh ra hai cạnh trùng nhau. + """ return f"e_{source}__{target}" def new_workflow(name: str = "Untitled flow") -> Workflow: + """Tạo một luồng rỗng với id mới.""" return Workflow(id=_mint_id("wf"), name=name) def new_custom_agent(name: str = "") -> CustomAgent: + """Tạo một agent tự tạo rỗng với id mới.""" return CustomAgent(id=_mint_id("agent"), name=name) # ---- workflow store ------------------------------------------------------ def workflows_dir() -> Path: + """Thư mục chứa file luồng.""" return WORKFLOWS_DIR def list_workflows(directory: Optional[Path] = None) -> List[Workflow]: + """Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng.""" directory = directory or WORKFLOWS_DIR if not directory.exists(): return [] @@ -232,6 +260,7 @@ def list_workflows(directory: Optional[Path] = None) -> List[Workflow]: def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path: + """Ghi một luồng ra ``.json``, tự tạo thư mục nếu chưa có.""" directory = directory or WORKFLOWS_DIR directory.mkdir(parents=True, exist_ok=True) path = directory / f"{wf.id}.json" @@ -242,6 +271,7 @@ def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path: def get_workflow(wf_id: str, directory: Optional[Path] = None) -> Optional[Workflow]: + """Đọc một luồng theo id; ``None`` nếu không có.""" directory = directory or WORKFLOWS_DIR path = directory / f"{wf_id}.json" if not path.exists(): @@ -280,6 +310,7 @@ def tr_copy_suffix() -> str: def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None: + """Xoá file luồng theo id; không có thì bỏ qua.""" directory = directory or WORKFLOWS_DIR path = directory / f"{wf_id}.json" if path.exists(): @@ -291,10 +322,12 @@ def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None: # ---- custom-agent store -------------------------------------------------- def agents_dir() -> Path: + """Thư mục chứa file agent tự tạo.""" return AGENTS_DIR def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]: + """Liệt kê mọi agent tự tạo; thư mục chưa có thì trả list rỗng.""" directory = directory or AGENTS_DIR if not directory.exists(): return [] @@ -308,6 +341,7 @@ def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]: def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> Path: + """Ghi một agent tự tạo ra ``.json``.""" directory = directory or AGENTS_DIR directory.mkdir(parents=True, exist_ok=True) path = directory / f"{agent.id}.json" @@ -316,6 +350,7 @@ def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> P def delete_custom_agent(agent_id: str, directory: Optional[Path] = None) -> None: + """Xoá file agent tự tạo theo id; không có thì bỏ qua.""" directory = directory or AGENTS_DIR path = directory / f"{agent_id}.json" if path.exists(): @@ -340,6 +375,11 @@ def compute_waves(nodes: List[Node], edges: List[Edge]) -> Dict[str, int]: limit = len(nodes) + 1 def depth(nid: str, seen: frozenset) -> int: + """Độ sâu của một node = lớp chạy của nó. + + Có nhớ kết quả và chặn theo ``limit``: đồ thị có vòng sẽ khiến đệ quy chạy + mãi, nên gặp node đã thấy trong nhánh hiện tại thì dừng. + """ if nid in wave: return wave[nid] if nid in seen or len(seen) > limit: @@ -360,12 +400,14 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int: parent = {n.id: n.id for n in nodes} def find(x): + """Tìm gốc của một phần tử, kèm nén đường đi (union-find).""" while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): + """Gộp hai tập hợp lại làm một (union-find).""" ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb @@ -379,6 +421,7 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int: # ---- run-stage compilation ---------------------------------------------- @dataclass class RunStage: + """Một chặng chạy: ứng với một node, hoặc một nhánh song song / bước gộp của nó.""" id: str # node id, or "__p" / "__pjoin" node_id: str # which canvas node this stage maps back onto wave: int @@ -395,6 +438,7 @@ PLAN_MODE_PREAMBLE = ( def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str: + """Ghép nội dung các skill được chọn thành một khối chèn vào prompt.""" parts = [] for name in skills or []: content = (skill_map.get(name) or "").strip() @@ -407,6 +451,9 @@ def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str: def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: str) -> str: + """Phần prompt dùng chung cho cả ba loại chặng: chỉ dẫn của bước, khối skill, + và ngữ cảnh thêm từ các bước trước. + """ parts = [] if step.instructions.strip(): parts.append(step.instructions.strip()) @@ -423,6 +470,7 @@ def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: s def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho một bước chạy tuần tự bình thường.""" head = f'You are the {step.role} agent for the workflow step "{step.label}".' body = _shared_prompt_parts(step, skill_map, extra_context) return f"{head}\n{body}".strip() @@ -430,6 +478,11 @@ def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str], skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho một sub-agent chạy song song. + + Nói rõ nó đang chạy CÙNG LÚC với những ai và phải ở trong phạm vi của mình — + không có câu đó, các sub-agent hay làm chồng việc của nhau. + """ peer_txt = ", ".join(p for p in peers if p) or "peers" head = (f'You are the "{sub.agent}" agent working concurrently (in parallel with ' f'{peer_txt}) on the workflow step "{step.label}". Stay within your own scope.') @@ -443,6 +496,7 @@ def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str], def build_join_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho bước gộp: hợp nhất đầu ra của các sub-agent thành một kết quả.""" head = (f'You are the coordinator for the parallel step "{step.label}". Consolidate the ' f"outputs of the sub-agents (provided above as prior outputs) into one coherent result.") body = _shared_prompt_parts(step, skill_map, extra_context) @@ -461,6 +515,9 @@ def compile_run_stages(nodes: List[Node], edges: List[Edge], stages: List[RunStage] = [] def finalize(prompt: str, preset: str) -> tuple: + """Chốt prompt của một chặng: áp phạm vi theo preset, và thêm lời mở đầu chế + độ lập kế hoạch nếu đang chạy ở chế độ đó. + """ scope = PRESET_SCOPES.get(preset) if plan_mode: prompt = PLAN_MODE_PREAMBLE + prompt diff --git a/core/co4e_builtins.py b/core/co4e_builtins.py index 179f849..71b661d 100644 --- a/core/co4e_builtins.py +++ b/core/co4e_builtins.py @@ -15,6 +15,7 @@ from .co4e import ( @dataclass class BuiltinAgent: + """Một agent dựng sẵn của Co4E: slug, tên, vai trò và prompt mặc định.""" slug: str name: str role: str diff --git a/core/co4e_run_manager.py b/core/co4e_run_manager.py index 1695401..c253daf 100644 --- a/core/co4e_run_manager.py +++ b/core/co4e_run_manager.py @@ -28,6 +28,7 @@ _HISTORY_CAP = 500 # keep the most-recent N runs on disk def _now_str() -> str: + """Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' cho lịch sử run.""" from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M") @@ -44,6 +45,11 @@ class RunHandle: def __init__(self, run_id: str, wf_id: str, name: str, total: int, plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "", project_id: str = ""): + """Một lượt chạy workflow đang sống trong bộ nhớ. + + ``total`` âm bị kẹp về 0 — số bước không thể âm, và để lọt xuống thì thanh + tiến độ vẽ ngược. + """ self.id = run_id self.wf_id = wf_id self.name = name @@ -64,9 +70,11 @@ class RunHandle: @property def running(self) -> bool: + """Lượt chạy này còn đang chạy hay không.""" return self.status == "running" def progress_text(self) -> str: + """Chuỗi tiến độ 'xong/tổng'; chưa biết tổng thì hiện trạng thái.""" return f"{self.done}/{self.total}" if self.total else self.status # ---- persistence ------------------------------------------------------ @@ -87,6 +95,7 @@ class RunHandle: @classmethod def from_record(cls, rec: dict) -> "RunHandle": + """Dựng lại một ``RunHandle`` từ bản ghi đọc trong lịch sử trên đĩa.""" from .co4e import workflow_from_dict rec = dict(rec or {}) h = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")), @@ -109,10 +118,18 @@ class RunHandle: class Co4ERunManager(QObject): + """Quản lý vòng đời nhiều lượt chạy luồng Co4E cùng lúc. + + Flow Status lọc theo project, nên hầu hết truy vấn ở đây chỉ tính run thuộc + workspace ĐANG chọn — xem ``_belongs``. + """ changed = Signal() # any run's status/progress changed → refresh views event = Signal(str, dict) # (run_id, ev) — node-level events, for mirroring def __init__(self, ctx): + """Dựng bộ quản lý run và khôi phục lịch sử cũ ngay, để tab Flow Status có nội + dung ngay khi mở chứ không trống cho tới lần chạy đầu tiên. + """ super().__init__() self.ctx = ctx self._runs: Dict[str, RunHandle] = {} @@ -126,10 +143,12 @@ class Co4ERunManager(QObject): # ---- persistence ------------------------------------------------------ def _history_path(self) -> Path: + """Đường dẫn file lịch sử run.""" from .co4e import CO4E_DIR return CO4E_DIR / "run_history.json" def _load_history(self) -> None: + """Khôi phục lịch sử run từ đĩa lúc khởi động; file hỏng thì bỏ qua lặng lẽ.""" path = self._history_path() try: data = json.loads(path.read_text(encoding="utf-8")) @@ -149,6 +168,7 @@ class Co4ERunManager(QObject): self._seq = max_seq # avoid minting ids that collide with history def _save_history(self) -> None: + """Ghi ``_HISTORY_CAP`` run gần nhất xuống đĩa.""" path = self._history_path() runs = list(self._runs.values())[-_HISTORY_CAP:] payload = {"runs": [h.to_record() for h in runs]} @@ -163,6 +183,7 @@ class Co4ERunManager(QObject): # ---- lifecycle -------------------------------------------------------- def _next_id(self) -> str: + """Sinh id run kế tiếp dạng 'runN'.""" self._seq += 1 return f"run{self._seq}" @@ -198,6 +219,7 @@ class Co4ERunManager(QObject): run_label = handle.name def job(worker: AgentWorker): + """Chạy nền: thực thi luồng, chuyển tiếp sự kiện tiến độ và cờ huỷ.""" return co4e_runner.run_workflow( ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled, plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed, @@ -215,6 +237,7 @@ class Co4ERunManager(QObject): # ---- worker callbacks ------------------------------------------------- def _on_event(self, run_id: str, ev: dict) -> None: + """Nhận sự kiện từ luồng đang chạy và cập nhật trạng thái/tiến độ của run.""" handle = self._runs.get(run_id) if handle is not None and isinstance(ev, dict): t = ev.get("type") @@ -229,6 +252,10 @@ class Co4ERunManager(QObject): self.event.emit(run_id, ev) def _on_finished(self, run_id: str) -> None: + """Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'. + + Lẽ ra không xảy ra, nhưng thiếu bước này thì run kẹt ở 'running' mãi. + """ handle = self._runs.get(run_id) if handle is not None and handle.status == "running": # job returned without a run_done event (shouldn't happen) — settle it @@ -236,6 +263,7 @@ class Co4ERunManager(QObject): self.changed.emit() def _on_failed(self, run_id: str, err: str) -> None: + """Job ném lỗi: ghi lỗi vào bản ghi run và báo ra ngoài.""" handle = self._runs.get(run_id) if handle is not None: handle.status = "error" @@ -245,6 +273,7 @@ class Co4ERunManager(QObject): # ---- control ---------------------------------------------------------- def stop(self, run_id: str) -> None: + """Yêu cầu dừng một run đang chạy.""" handle = self._runs.get(run_id) if handle is not None and handle.worker is not None and handle.running: handle.worker.request_stop() @@ -253,6 +282,7 @@ class Co4ERunManager(QObject): def stop_all(self) -> None: # Only the CURRENT workspace's runs (Flow Status is per-project). + """Dừng mọi run của workspace đang chọn.""" for run_id in [r for r, h in self._runs.items() if self._belongs(h)]: self.stop(run_id) @@ -269,6 +299,7 @@ class Co4ERunManager(QObject): self.changed.emit() def remove(self, run_id: str) -> None: + """Xoá một run khỏi lịch sử; đang chạy thì dừng trước.""" handle = self._runs.get(run_id) if handle is not None and handle.running: self.stop(run_id) @@ -277,6 +308,7 @@ class Co4ERunManager(QObject): def clear_finished(self) -> None: # Only clear finished runs of the CURRENT workspace. + """Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy.""" for run_id in [r for r, h in self._runs.items() if not h.running and self._belongs(h)]: self._runs.pop(run_id, None) self.changed.emit() @@ -295,9 +327,11 @@ class Co4ERunManager(QObject): return list(self._runs.values()) def get(self, run_id: str) -> Optional[RunHandle]: + """Bản ghi của một run theo id; ``None`` nếu không có.""" return self._runs.get(run_id) def active_count(self) -> int: + """Số run đang chạy của workspace đang chọn.""" return sum(1 for h in self._runs.values() if h.running and self._belongs(h)) def set_current_project(self, project_id: str) -> None: @@ -320,6 +354,11 @@ class Co4ERunManager(QObject): # tab), not in the config/install folder. One subfolder per flow keeps # runs tidy. Falls back to the global Cowork output dir when no workspace # is selected. + """Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có. + + Ưu tiên thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm + việc (màn Thư mục), không rơi vào thư mục cài đặt. + """ from .co4e import slugify base = self._output_root if base is None: diff --git a/core/co4e_runner.py b/core/co4e_runner.py index 5308700..96acad9 100644 --- a/core/co4e_runner.py +++ b/core/co4e_runner.py @@ -29,6 +29,9 @@ CancelFn = Callable[[], bool] def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]: + """Bảng ``{node: các node đứng trước}`` — dùng để gom đầu ra của bước trước làm + ngữ cảnh cho bước sau. + """ ids = {n.id for n in nodes} preds: Dict[str, List[str]] = {n.id: [] for n in nodes} for e in edges: @@ -38,6 +41,7 @@ def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]: def _label_of(nodes: List[Node], node_id: str) -> str: + """Nhãn hiển thị của một node; trả về chính id nếu không tìm thấy.""" for n in nodes: if n.id == node_id: return n.data.label @@ -62,6 +66,10 @@ def _attachments_text(node, out_dir=None) -> str: parts, budget = [], _MAX_ATTACH_CHARS def _read_into(path, label, indent=""): + """Đọc một tệp đính kèm vào phần ngữ cảnh, trừ dần vào hạn mức ký tự chung. + + Có hạn mức vì vài tệp lớn là đủ đẩy cả lượt chạy vượt cửa sổ ngữ cảnh. + """ nonlocal budget name = _P(path).name if is_image(path): @@ -97,6 +105,7 @@ def _attachments_text(node, out_dir=None) -> str: def _last_assistant_text(messages: List[dict]) -> str: + """Nội dung trả lời cuối cùng của assistant; '' nếu không có.""" for m in reversed(messages): if m.get("role") == "assistant" and m.get("content"): return str(m["content"]) @@ -245,6 +254,7 @@ def run_workflow(ctx, nodes: List[Node], edges: List[Edge], out_dir: Path, # Group compiled stages by wave, preserving per-node context threading. def extra_context_for(node_id: str) -> Dict[str, str]: + """Ngữ cảnh thêm cho một bước: tệp đính kèm của nó cộng đầu ra của các bước đứng trước.""" parts = [] att = _attachments_text(by_id.get(node_id), out_dir) if att: diff --git a/core/code_agent.py b/core/code_agent.py index 9cb47c6..088df89 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -31,6 +31,11 @@ _TOOL_LINE = re.compile(r"@@TOOL\s+(\w+)\s+(\{.*\})", re.DOTALL) def code_system_prompt(workdir: Path, has_memory: bool = False, plan: bool = False, has_plan_tool: bool = False, has_ms365: bool = False) -> str: + """Prompt hệ thống cho Code agent, ghép theo năng lực thật của lượt chạy. + + Chỉ liệt kê những tool đang BẬT, và thêm ghi chú chế độ lập kế hoạch khi cần — + nói với model về một tool nó không có sẽ khiến nó gọi rồi báo lỗi. + """ names = ", ".join(t.name for t in TOOL_SPECS) plan_note = ("PLAN MODE: only analyze and propose a detailed plan; do NOT write files or run " "commands. When the user asks to gencode/implement, the app switches to ACT.\n" diff --git a/core/codebase_memory.py b/core/codebase_memory.py index 1684908..7c2acd3 100644 --- a/core/codebase_memory.py +++ b/core/codebase_memory.py @@ -26,6 +26,7 @@ _INDEX_TIMEOUT = 900 class CodebaseMemoryError(RuntimeError): + """Lỗi khi gọi công cụ codebase-memory-mcp bên ngoài.""" pass @@ -75,14 +76,24 @@ def _extract_json(text: str): class CodebaseMemory: + """Vỏ bọc quanh CLI ``codebase-memory-mcp``: đánh chỉ mục và tra cứu mã nguồn. + + Đây là phần mềm ngoài, có thể không được cài — luôn kiểm :meth:`available` + trước khi dùng. + """ def __init__(self, binary_path: str = ""): + """Tìm file thực thi codebase-memory; không có thì ``available`` là False và + mọi lượt gọi về sau tự bỏ qua. + """ self.binary = resolve_binary(binary_path) @property def available(self) -> bool: + """Đã tìm thấy CLI trên máy chưa.""" return self.binary is not None def _run(self, tool: str, args: Dict[str, Any], timeout: int) -> Dict[str, Any]: + """Gọi một tool của CLI và trả kết quả JSON; chưa cài thì báo lỗi kèm hướng dẫn.""" if not self.binary: raise CodebaseMemoryError( "codebase-memory-mcp is not installed. See the instructions in Settings." @@ -107,12 +118,15 @@ class CodebaseMemory: # ---- high level ops --------------------------------------------- def index_repository(self, repo_path: str) -> Dict[str, Any]: + """Đánh chỉ mục một repository (chạy lâu — dùng hạn giờ dài hơn).""" return self._run("index_repository", {"repo_path": str(repo_path)}, _INDEX_TIMEOUT) def list_projects(self) -> Dict[str, Any]: + """Danh sách project đã được đánh chỉ mục.""" return self._run("list_projects", {}, _QUERY_TIMEOUT) def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Gọi một tool bất kỳ, tự chọn hạn giờ theo loại việc.""" timeout = _INDEX_TIMEOUT if tool == "index_repository" else _QUERY_TIMEOUT return self._run(tool, args, timeout) @@ -187,6 +201,9 @@ def make_executor(mem: CodebaseMemory): """Return an executor(name, args) -> {ok, output} for cmem_* tools.""" def execute(name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Bộ thực thi tool codebase-memory cho agent; tên tool lạ thì trả về lỗi thay + vì ném ngoại lệ. + """ cli_tool = _CLI_NAME.get(name) if not cli_tool: return {"ok": False, "output": f"Unsupported codebase-memory tool: {name}"} diff --git a/core/codebase_memory_ui.py b/core/codebase_memory_ui.py index 446f350..dc4024f 100644 --- a/core/codebase_memory_ui.py +++ b/core/codebase_memory_ui.py @@ -33,6 +33,9 @@ class CmemUiError(RuntimeError): asset) — a different remedy than a generic startup/timeout failure.""" def __init__(self, message: str, no_ui_build: bool = False): + """``no_ui_build`` đánh dấu trường hợp riêng: chạy được nhưng bản cài không kèm + phần giao diện — thông báo cho người dùng phải khác hẳn lỗi chạy thường. + """ super().__init__(message) self.no_ui_build = no_ui_build @@ -41,16 +44,21 @@ class CodebaseMemoryUiServer: """One ``codebase-memory-mcp --ui`` process, started on demand.""" def __init__(self, binary_path: str = "", port: int = DEFAULT_PORT): + """Chuẩn bị chỗ chạy máy chủ giao diện; chưa khởi động tiến trình nào.""" self.binary = resolve_binary(binary_path) self.port = port self._proc: Optional[subprocess.Popen] = None @property def url(self) -> str: + """Địa chỉ để mở giao diện. Chỉ nghe trên 127.0.0.1 — đây là công cụ cục bộ, + không mở ra mạng. + """ return f"http://127.0.0.1:{self.port}/" @property def running(self) -> bool: + """Tiến trình máy chủ còn sống không.""" return self._proc is not None and self._proc.poll() is None def start(self, repo_path: str = "") -> str: @@ -75,6 +83,9 @@ class CodebaseMemoryUiServer: no_ui_event = threading.Event() def _reader() -> None: + """Chạy nền: đọc đầu ra của tiến trình, giữ lại để báo lỗi và bật cờ khi thấy + dấu hiệu bản cài không có phần giao diện. + """ try: stream = self._proc.stdout if stream is None: @@ -111,6 +122,11 @@ class CodebaseMemoryUiServer: raise CmemUiError(f"Hết thời gian chờ UI trên cổng {self.port}.") def stop(self) -> None: + """Dừng máy chủ. Xin dừng tử tế trước, quá 3 giây thì buộc tắt. + + Mọi lỗi đều bị nuốt có chủ ý: đây là dọn dẹp lúc thoát, ném lỗi ở đây chỉ + làm kẹt đường thoát của cả ứng dụng. + """ proc, self._proc = self._proc, None if proc is not None and proc.poll() is None: try: diff --git a/core/context_budget.py b/core/context_budget.py index 6f4301c..b26b737 100644 --- a/core/context_budget.py +++ b/core/context_budget.py @@ -33,6 +33,9 @@ _MODEL_LIMITS = { def model_context_limit(model: str) -> int: + """Cửa sổ ngữ cảnh (token) của một model, dò theo tiền tố tên dài nhất khớp + trong bảng; không khớp gì thì lấy ``DEFAULT_LIMIT``. + """ m = (model or "").lower() best = 0 limit = DEFAULT_LIMIT @@ -43,6 +46,7 @@ def model_context_limit(model: str) -> int: def _ctx_conf(config) -> Dict[str, Any]: + """Nhóm cấu hình ``context``; không có config thì trả dict rỗng.""" if config is None: return {} try: @@ -59,11 +63,13 @@ def context_limit(config, model: str = "") -> int: def auto_compact_enabled(config) -> bool: + """Có tự nén lịch sử khi gần đầy ngữ cảnh không (mặc định bật).""" conf = _ctx_conf(config) return bool(conf.get("auto_compact", True)) def threshold(config) -> float: + """Ngưỡng nén, tính theo tỉ lệ cửa sổ ngữ cảnh đã dùng (mặc định 0,8).""" conf = _ctx_conf(config) try: t = float(conf.get("compact_threshold", DEFAULT_THRESHOLD)) @@ -73,6 +79,9 @@ def threshold(config) -> float: def _msg_text(m: Dict[str, Any]) -> str: + """Rút phần văn bản của một tin nhắn, kể cả khi nội dung là danh sách block + (tin nhắn có ảnh). + """ c = m.get("content", "") if isinstance(c, str): return c @@ -81,11 +90,17 @@ def _msg_text(m: Dict[str, Any]) -> str: def estimate_messages_tokens(messages: List[Dict[str, Any]]) -> int: + """Ước lượng tổng token của cả danh sách tin nhắn.""" return sum(estimate_tokens(_msg_text(m)) for m in messages) def should_compact(messages: List[Dict[str, Any]], limit: int, thresh: float = DEFAULT_THRESHOLD) -> bool: + """Đã đến lúc nén lịch sử chưa. + + Không nén khi hội thoại còn quá ngắn: nén một cuộc mới vài lượt thì mất nội + dung mà chẳng tiết kiệm được bao nhiêu. + """ if limit <= 0 or len(messages) <= _KEEP_RECENT + 2: return False return estimate_messages_tokens(messages) > limit * thresh @@ -99,6 +114,7 @@ _SUMMARY_PROMPT = ( def _summarize(provider, middle: List[Dict[str, Any]], cancel=None) -> str: + """Nhờ model tóm tắt phần giữa của hội thoại thành một đoạn ngắn.""" convo = "\n\n".join(f"[{m.get('role', '?')}] {_msg_text(m)}" for m in middle) try: a = provider.chat([{"role": "system", "content": _SUMMARY_PROMPT}, diff --git a/core/cron.py b/core/cron.py index 9d59cc1..4084011 100644 --- a/core/cron.py +++ b/core/cron.py @@ -15,10 +15,14 @@ _SEARCH_DAYS = 366 * 2 # give up after two years (an expression that never fir class CronError(ValueError): + """Biểu thức cron sai cú pháp.""" pass def _parse_field(spec: str, lo: int, hi: int) -> Set[int]: + """Đọc một trường cron thành tập giá trị: hỗ trợ ``*``, danh sách ``a,b``, + khoảng ``a-b`` và bước ``*/n``. + """ values: Set[int] = set() for part in spec.split(","): part = part.strip() @@ -55,7 +59,13 @@ def _parse_field(spec: str, lo: int, hi: int) -> Set[int]: class Cron: + """Biểu thức cron 5 trường (phút, giờ, ngày, tháng, thứ).""" def __init__(self, expression: str): + """Phân tích một biểu thức cron 5 trường. + + Sai số trường là ném ``CronError`` ngay tại đây chứ không đợi tới lúc chạy: + lịch sai giờ khó phát hiện hơn nhiều so với một lỗi lúc nhập. + """ fields = (expression or "").split() if len(fields) != 5: raise CronError("Cron expression needs exactly 5 fields: " @@ -69,6 +79,11 @@ class Cron: self._dow_star = fields[4].strip() == "*" def _day_matches(self, dt: datetime) -> bool: + """Ngày này có khớp biểu thức không. + + Theo chuẩn cron: khi cả trường NGÀY và trường THỨ đều được đặt cụ thể thì + khớp một trong hai là đủ (OR), chứ không phải cả hai (AND). + """ if dt.month not in self.months: return False cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0 → cron Sun=0 diff --git a/core/custom_agents.py b/core/custom_agents.py index 34cfc6f..c07a2f2 100644 --- a/core/custom_agents.py +++ b/core/custom_agents.py @@ -21,6 +21,14 @@ AGENTS_DIR = CONFIG_DIR / "agents" @dataclass class CustomAgent: + """Một agent do người dùng tự tạo: tên, mô tả, prompt mặc định và tuỳ chọn + provider/model riêng. + + Bỏ trống ``provider``/``model`` nghĩa là dùng theo bước gọi nó hoặc theo cấu + hình chung — nhờ vậy một agent viết một lần chạy được với mọi provider. + + Đã được ``core/co4e.py`` thay thế; giữ lại làm bản đối chiếu. + """ name: str description: str = "" prompt: str = "" # default task; a Flow sub-agent can still override it @@ -29,16 +37,25 @@ class CustomAgent: @property def slug(self) -> str: + """Tên rút gọn an toàn để đặt tên file, ví dụ "Trợ lý Code" -> "tro-ly-code". + Tên không còn ký tự hợp lệ nào thì rơi về "agent". + """ keep = "-_" s = "".join(c if (c.isalnum() or c in keep) else "-" for c in self.name.strip().lower()) return "-".join(filter(None, s.split("-"))) or "agent" def agents_dir() -> Path: + """Thư mục chứa file agent tự tạo.""" return AGENTS_DIR def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]: + """Đọc mọi agent trong thư mục, sắp theo tên file. + + File hỏng bị bỏ riêng lẻ chứ không làm hỏng cả danh sách — một file sai + không được phép làm mất hết agent còn lại. + """ if not directory.exists(): return [] agents: List[CustomAgent] = [] @@ -58,6 +75,11 @@ def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]: def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = "") -> Path: + """Ghi một agent xuống đĩa. + + Truyền ``old_name`` khi đổi tên: file cũ bị xoá trước, nếu không sẽ có hai + file cùng nội dung với hai tên khác nhau. + """ directory.mkdir(parents=True, exist_ok=True) if old_name and old_name != agent.name: delete_agent(old_name, directory) @@ -67,6 +89,9 @@ def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = def delete_agent(name: str, directory: Path = AGENTS_DIR) -> None: + """Xoá file của một agent theo tên. Không có file thì thôi; lỗi xoá bị nuốt, + không chặn giao diện. + """ path = directory / f"{CustomAgent(name=name).slug}.json" if path.exists(): try: diff --git a/core/custom_icons.py b/core/custom_icons.py index 6ccc69f..84dd629 100644 --- a/core/custom_icons.py +++ b/core/custom_icons.py @@ -18,15 +18,18 @@ _MAX_BYTES = 200_000 def icons_dir() -> Path: + """Thư mục chứa icon do người dùng thêm.""" return ICONS_DIR def slugify(name: str) -> str: + """Định danh an toàn cho tên file icon; rỗng thì trả về 'icon'.""" s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (name or "").strip().lower()) return "-".join(filter(None, s.split("-"))) or "icon" def list_custom(directory: Optional[Path] = None) -> List[str]: + """Tên các icon tự thêm; thư mục chưa có thì trả list rỗng.""" directory = directory or ICONS_DIR if not directory.exists(): return [] @@ -69,6 +72,7 @@ def add_from_file(path, name: str = "", directory: Optional[Path] = None) -> str def delete_custom(name: str, directory: Optional[Path] = None) -> None: + """Xoá một icon tự thêm; không có thì bỏ qua.""" directory = directory or ICONS_DIR path = directory / f"{slugify(name)}.svg" if path.exists(): diff --git a/core/d3_graph.py b/core/d3_graph.py index f7340f3..20b30c2 100644 --- a/core/d3_graph.py +++ b/core/d3_graph.py @@ -18,6 +18,7 @@ _CDN_D3 = '