Batch of fixes for defects tracked in "Task Tracking Template.xlsx" (sheet Defect Management), verified against the sheet's Root Cause/Cach xu ly columns before this commit: - DF-002: Co4E node status not reflected after tab switch + missing edit-lock on running/done nodes (node_property_panel.py, co4e_runs.py, co4e_workflow_crud.py, co4e_canvas_widget.py, co4e_flow_tabs.py, canvas_items.py) - DF-003: hide the run.bat console window unless the app exits with an error (run.bat, scripts/console_visibility.ps1 - new) - DF-004: floating Help Assistant icon covering the Send button after a window resize (presentation/shell/main_window.py) - DF-005: "block network" toggle didn't stop ICMP/raw-socket tools like ping (infrastructure/filesystem/command_tools.py, security/command_risk_classifier.py) - DF-006: Monitoring "gay nang khi log lon" - root cause was re-reading the ENTIRE audit log history every 3s tick, not missing pagination; bounded to a 30-day window (presentation/monitoring/monitoring_tab.py) AND added the "So dong/trang" page-size control the ticket also asked for (presentation/monitoring/shared/event_table.py, shared/filter_scaffold.py, tabs/action_logs_tab.py, tabs/mcp_tab.py, tabs/security_events_tab.py, i18n/agents_admin_tab.py) - DF-007: support choosing a OneDrive/SharePoint folder as a project's working directory via Microsoft Graph, downloaded as a local mirror with manual sync (core/projects.py, core/ms365_graph.py, core/cloud_workspace_sync.py - new, ui/ms365_signin_dialog.py - new, ui/cloud_folder_picker_dialog.py - new, i18n/cloud_workspace.py - new, ui/workspace_tab.py) - DF-008: AI-edit instruction box was a fixed-height single-line QLineEdit; replaced with an auto-expanding, Enter-to-send/Shift+Enter-newline input (presentation/folder/ai_file_editor_dialog.py) - DF-011: run_command failed with WinError 267 for a project whose per-turn output directory had never been created (application/conversations/core_runtime_adapter.py) DF-009 (AI-edit Apply/Discard buttons easy to miss) and DF-010 (AI reply language - dev-confirmed not a bug) are intentionally NOT part of this commit: DF-009 has no code fix yet (still "Assigned" in the sheet, only a UX recommendation was recorded), DF-010 was rejected as expected behavior. Tests: tests/test_cloud_workspace_sync.py, tests/test_ms365_cloud_dialogs.py, tests/test_ai_file_editor_input.py, tests/test_monitoring_page_size.py (all new, all passing). Full suite: 896 passed, 13 known-and-documented failures unrelated to this change (an existing core/audit_log.py bug, this checkout not being a git repo before now, and a repo/subprocess folder-naming mismatch affecting ~66 characterization tests) - see the sheet's DF-006 Evidence column for details.
115 lines
5.2 KiB
Python
115 lines
5.2 KiB
Python
"""Mirror a OneDrive/SharePoint folder to/from a local directory (DF-007).
|
|
|
|
This is deliberately NOT a general sync engine: every existing tool
|
|
(``run_command``, ``read_file``, ``write_file``...) operates on a real local
|
|
``Path`` (``Project.output_dir`` — see ``core/projects.py::Project.workspace_dir``),
|
|
and that contract does not change here. A cloud-backed project's
|
|
``output_dir`` still points at a real local folder; this module only knows how
|
|
to pull that folder's content down from Graph once, and push it back up once,
|
|
both on explicit user action (a button click) — there is no background
|
|
watcher, no continuous sync, no delete propagation, and no conflict
|
|
resolution beyond "whichever side ran last wins" for a given file. See the
|
|
DF-007 plan for why: OneDrive/SharePoint sync-client detection is unreliable,
|
|
so a local mirror + manual sync is the only predictable option that does not
|
|
touch the sandboxed command/file tools.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
from . import ms365_graph as graph
|
|
|
|
|
|
@dataclass
|
|
class SyncReport:
|
|
"""Kết quả một lượt tải xuống/đẩy lên — hiển thị cho người dùng sau khi chạy."""
|
|
transferred: int = 0
|
|
skipped_too_large: List[str] = field(default_factory=list)
|
|
errors: List[str] = field(default_factory=list)
|
|
|
|
|
|
def _list_children(token: str, cloud_source: Dict[str, str], remote_path: str) -> List[dict]:
|
|
provider = cloud_source.get("provider")
|
|
if provider == "sharepoint":
|
|
return graph.list_sharepoint_files(token, cloud_source["site_id"], remote_path)
|
|
return graph.list_onedrive_files(token, remote_path)
|
|
|
|
|
|
def _download_file(token: str, cloud_source: Dict[str, str], remote_path: str) -> bytes:
|
|
if cloud_source.get("provider") == "sharepoint":
|
|
return graph.download_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path)
|
|
return graph.download_onedrive_file_bytes(token, remote_path)
|
|
|
|
|
|
def _upload_file(token: str, cloud_source: Dict[str, str], remote_path: str, data: bytes) -> None:
|
|
if cloud_source.get("provider") == "sharepoint":
|
|
graph.upload_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path, data)
|
|
else:
|
|
graph.upload_onedrive_file_bytes(token, remote_path, data)
|
|
|
|
|
|
def download_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
|
|
"""Tải toàn bộ cây thư mục ``cloud_source['remote_path']`` xuống ``local_dir``,
|
|
giữ nguyên cấu trúc thư mục con. Ghi đè file local nếu đã tồn tại (một
|
|
chiều: cloud thắng). Không xoá file local nào không còn ở phía cloud."""
|
|
report = SyncReport()
|
|
root_remote = cloud_source.get("remote_path", "")
|
|
local_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _walk(remote_path: str, local_sub: Path) -> None:
|
|
try:
|
|
children = _list_children(token, cloud_source, remote_path)
|
|
except graph.Ms365GraphError as exc:
|
|
report.errors.append(f"{remote_path or '/'}: {exc}")
|
|
return
|
|
for item in children:
|
|
name = item.get("name", "")
|
|
if not name:
|
|
continue
|
|
child_remote = f"{remote_path}/{name}" if remote_path else name
|
|
child_local = local_sub / name
|
|
if "folder" in item:
|
|
child_local.mkdir(parents=True, exist_ok=True)
|
|
_walk(child_remote, child_local)
|
|
else:
|
|
try:
|
|
data = _download_file(token, cloud_source, child_remote)
|
|
child_local.write_bytes(data)
|
|
report.transferred += 1
|
|
except graph.Ms365GraphError as exc:
|
|
report.errors.append(f"{child_remote}: {exc}")
|
|
|
|
_walk(root_remote, local_dir)
|
|
return report
|
|
|
|
|
|
def upload_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
|
|
"""Đẩy mọi file dưới ``local_dir`` lên đúng đường dẫn tương ứng phía cloud
|
|
(tạo mới hoặc ghi đè). Một chiều: local thắng cho từng file được duyệt qua.
|
|
Không xoá file cloud nào đã bị xoá ở local, không phát hiện xung đột."""
|
|
report = SyncReport()
|
|
root_remote = cloud_source.get("remote_path", "")
|
|
local_dir = Path(local_dir)
|
|
for dirpath, _dirnames, filenames in os.walk(local_dir):
|
|
rel_dir = Path(dirpath).relative_to(local_dir)
|
|
for fname in filenames:
|
|
local_file = Path(dirpath) / fname
|
|
rel_parts = [] if str(rel_dir) == "." else list(rel_dir.parts)
|
|
rel_parts.append(fname)
|
|
child_remote = "/".join(([root_remote] if root_remote else []) + rel_parts)
|
|
try:
|
|
data = local_file.read_bytes()
|
|
_upload_file(token, cloud_source, child_remote, data)
|
|
report.transferred += 1
|
|
except graph.Ms365GraphError as exc:
|
|
if "too large" in str(exc):
|
|
report.skipped_too_large.append(child_remote)
|
|
else:
|
|
report.errors.append(f"{child_remote}: {exc}")
|
|
except OSError as exc:
|
|
report.errors.append(f"{child_remote}: {exc}")
|
|
return report
|