Compare commits

..
Author SHA1 Message Date
duylh19andClaude Opus 5 57af508971 fix(sandbox): chặn mạng thật sự cho mọi tool NETWORK của agent
CI / test (pull_request) Canceled after 0s
block_network trước đây chỉ được run_command đọc tới, nên fetch_url,
jira_search, jira_get_issue và install_package vẫn ra internet bình thường
trong khi Monitoring báo Mạng - Bị chặn. Thêm cổng chặn ngay đầu bốn tool
đó, trước mọi lời gọi mạng, kèm test hồi quy và guardrail theo
BUILT_IN_CAPABILITIES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:33:26 +09:00
duylh19andClaude Opus 5 d0df96c726 fix(workspace): Cowork/GraphRAG chỉ hiện khi đã chọn project
Mở app lên là hai mục đã hiện sẵn dù người dùng chưa bấm chọn project nào.
Cổng này có hai mặt và cả hai đều hở:

1. refresh() mặc định row_to_select = 0, nên lúc mở app (_current_id rỗng)
   danh sách tự chọn hộ project đầu tiên -> _update_tab_visibility(True).
   Cùng đường đó còn làm app nhảy sang project khác ngay sau khi xoá project
   đang mở. Đổi mặc định thành -1; có keep khớp thì vẫn giữ đúng dòng cũ.

2. NavRailMixin._rebuild_nav dựng hàng bị cổng đóng ở dạng mờ kèm tooltip
   ("shown instead of hidden") nên chúng vẫn nằm trên menu trái. Nay bỏ hẳn
   hàng cho tới khi chọn project.

Mặt 1 làm selected_project_id() trả rỗng, khiến bộ chọn project trên rail rơi
về mục 0 và chỉ vào project đầu danh sách trong khi cổng vẫn đóng — hai chỗ
nói hai đằng. Thêm mục "Chọn project…" (khoá app.nav.pick_project, khác
no_project: đã có project, chỉ chưa chọn) để rail nói đúng trạng thái.

tools/check_nav.py và một bài trong test_startup_landing.py đang chốt thiết
kế cũ; cập nhật để chốt hành vi mới. Bài startup giữ nguyên từng assert, chỉ
đổi cách mở cổng từ cửa sau setTabVisible sang chọn project thật.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:08 +09:00
duylh19andClaude Opus 5 10799dc67b feat(settings): bỏ khoá mật khẩu ở Sandbox Security Layer
Bốn công tắc của nhóm này (xác nhận lệnh, chặn mạng, bật lớp bảo mật agent,
AI kiểm tra lệnh) dựng ra ở trạng thái setEnabled(False) và chỉ mở khi nhập
đúng mật khẩu. Theo yêu cầu, bỏ hẳn bước đó: form luôn bật/tắt được.

Gỡ ô nhập, nút Mở khoá, nhãn trạng thái khoá, _sandbox_unlock() và
_sandbox_password_matches(); dọn 10 khoá i18n thành chết và 2 field trong
tools/check_dialogs.py. Giữ nguyên agent_security.sandbox_pw ở config.py —
yêu cầu chỉ nói tới màn hình, không nói tới tầng cấu hình.

Khoá này vốn không phải rào bảo mật: docstring của _sandbox_unlock() đã tự
ghi "khoá phía giao diện để chặn bấm nhầm ... KHÔNG phải cơ chế bảo mật
thật". Rào thật nằm ở sandbox lúc chạy lệnh. Vùng này thuộc diện SECURITY.md
yêu cầu Cowork Team soát thêm.

10 bài test cũ (SEC-20260907-01) chốt các đường không được mở khoá nay mất
đối tượng kiểm, thay bằng 12 bài chốt hành vi mới: bốn công tắc sửa được
ngay, không còn widget mật khẩu, kèm guardrail quét mã nguồn chặn khoá lại.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:31:49 +09:00
13 changed files with 54 additions and 274 deletions
Binary file not shown.
-6
View File
@@ -234,12 +234,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"}, "monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"monitoring.page_size_label": { "monitoring.page_size_label": {
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"}, "en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
"monitoring.page_indicator": {
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
"monitoring.page_prev": {
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
"monitoring.page_next": {
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
"monitoring.pricing_title": { "monitoring.pricing_title": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)", "en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"vi": "Bảng giá model (USD / 1 triệu token)"}, "vi": "Bảng giá model (USD / 1 triệu token)"},
+4 -4
View File
@@ -10,13 +10,13 @@ from typing import List, Optional
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QMenu from PySide6.QtWidgets import QMenu
from ...core import co4e from ...core import co4e
from ...core.co4e import STEP_RUNNING from ...core.co4e import STEP_DONE, STEP_RUNNING
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import ask_text from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names from ...presentation.co4e.co4e_chat_view import _skill_names
_LOCKED_NODE_STATUSES = (STEP_RUNNING,) _LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
class Co4EWorkflowCrudMixin: class Co4EWorkflowCrudMixin:
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
def _on_node_selected(self, node_id: str) -> None: def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập. """Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
vẫn sửa lại được bình thường. tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
""" """
for n in self.canvas.nodes(): for n in self.canvas.nodes():
if n.id == node_id: if n.id == node_id:
+5 -5
View File
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
def set_locked(self, locked: bool) -> None: def set_locked(self, locked: bool) -> None:
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước. """Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
phải cả panel. chỉ ô nhập liệu bị khoá, không phải cả panel.
""" """
self._locked = locked self._locked = locked
editable = not locked editable = not locked
+2 -3
View File
@@ -39,9 +39,8 @@ _UNBOUNDED_PAGE_SIZE = 100_000
# day-sharded JSONL — unbounded start/end means EVERY day file ever written # day-sharded JSONL — unbounded start/end means EVERY day file ever written
# gets re-read and re-parsed on EVERY tick, which is what actually made # gets re-read and re-parsed on EVERY tick, which is what actually made
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in # Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
# rendering (EventTable paginates client-side, 5-100 rows/page — see # rendering (EventTable already caps display at 300 rows — see
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history # shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
# read.
# 30 days is a live-monitoring window, not a hard retention limit — nothing # 30 days is a live-monitoring window, not a hard retention limit — nothing
# is deleted, older days are simply not re-read on every 3s tick. # is deleted, older days are simply not re-read on every 3s tick.
_LOG_WINDOW_DAYS = 30 _LOG_WINDOW_DAYS = 30
+11 -55
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
from PySide6.QtGui import QBrush, QColor from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time from .formatters import agent_avatar_icon, fmt_event_time
_DEFAULT_PAGE_SIZE = 20 _MAX_ROWS = 300
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100) PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
class _TimeItem(QTableWidgetItem): class _TimeItem(QTableWidgetItem):
@@ -63,12 +63,6 @@ class EventTable(QTableWidget):
"secret_in_output": "warning", "secret_in_output": "warning",
} }
# Emitted whenever the rendered page changes (new data, page-size change,
# or prev/next navigation) — args are (current_page, page_count), both
# 1-based-friendly in that current_page is 0-indexed but page_count is a
# plain count. filter_scaffold.py's pager label/buttons listen to this.
page_changed = Signal(int, int)
def __init__(self, show_result: bool = True): def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS). # Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát. """Bảng sự kiện dùng chung của các tab Giám sát.
@@ -77,10 +71,8 @@ class EventTable(QTableWidget):
là thất bại nên cột ấy chỉ tốn chỗ. là thất bại nên cột ấy chỉ tốn chỗ.
""" """
self._show_result = show_result self._show_result = show_result
self._page_size = _DEFAULT_PAGE_SIZE self._page_size = _MAX_ROWS
self._current_page = 0
self._last_events: List[dict] = [] self._last_events: List[dict] = []
self._sorted_events: List[dict] = []
super().__init__(0, 7 if show_result else 6) super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers) self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows) self.setSelectionBehavior(QTableWidget.SelectRows)
@@ -113,56 +105,21 @@ class EventTable(QTableWidget):
"""Số dòng đang hiển thị mỗi trang.""" """Số dòng đang hiển thị mỗi trang."""
return self._page_size return self._page_size
def page_count(self) -> int:
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
if not self._sorted_events:
return 1
return -(-len(self._sorted_events) // self._page_size) # ceil div
def current_page(self) -> int:
"""Trang đang hiển thị, đánh số từ 0."""
return self._current_page
def go_to_page(self, page: int) -> None:
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
self._current_page = page
self._render_current_page()
def next_page(self) -> None:
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
self.go_to_page(self._current_page + 1)
def prev_page(self) -> None:
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
self.go_to_page(self._current_page - 1)
def set_page_size(self, n: int) -> None: def set_page_size(self, n: int) -> None:
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ """Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
liệu đã có sẵn (không cần refresh lại từ nguồn).""" (không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
self._page_size = n self._page_size = n
self._current_page = 0 self.set_events(self._last_events)
self._render_current_page()
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo """Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size``
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page`` (đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
trước (DF-006)."""
self._last_events = events
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
self._current_page = 0
self._render_current_page()
def _render_current_page(self) -> None:
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
từ ``self._sorted_events`` đã sắp sẵn.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai. nạp chậm đi theo bậc hai.
""" """
self._current_page = max(0, min(self._current_page, self.page_count() - 1)) self._last_events = events
start = self._current_page * self._page_size events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
events = self._sorted_events[start:start + self._page_size]
self.setSortingEnabled(False) self.setSortingEnabled(False)
self.setRowCount(len(events)) self.setRowCount(len(events))
for row, ev in enumerate(events): for row, ev in enumerate(events):
@@ -207,7 +164,6 @@ class EventTable(QTableWidget):
self.setItem(row, col, item) self.setItem(row, col, item)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", "")) self.apply_filter(getattr(self, "_filter_needle", ""))
self.page_changed.emit(self._current_page, self.page_count())
def apply_filter(self, needle: str) -> None: def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường).""" """Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
@@ -95,52 +95,20 @@ def build_filter_scaffold(
if with_page_size and isinstance(table, EventTable): if with_page_size and isinstance(table, EventTable):
# DF-006: the item-per-page count was never surfaced anywhere in # DF-006: the item-per-page count was never surfaced anywhere in
# the UI (design called for it) — EventTable already trims to a # the UI (design called for it) — EventTable already trims to a
# page size internally, this just makes that number visible AND # page size internally (default 300), this just makes that
# user-choosable instead of a fixed constant. # number visible AND user-choosable instead of a fixed constant.
page_size_lbl = QLabel(tr("monitoring.page_size_label")) page_size_lbl = QLabel(tr("monitoring.page_size_label"))
page_size_combo = QComboBox() page_size_combo = QComboBox()
for n in PAGE_SIZE_OPTIONS: for n in PAGE_SIZE_OPTIONS:
page_size_combo.addItem(str(n), n) page_size_combo.addItem(str(n), n)
current = table.page_size() current = table.page_size()
page_size_combo.setCurrentIndex( page_size_combo.setCurrentIndex(
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0) PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
page_size_combo.currentIndexChanged.connect( page_size_combo.currentIndexChanged.connect(
lambda i: table.set_page_size(page_size_combo.itemData(i))) lambda i: table.set_page_size(page_size_combo.itemData(i)))
row.addWidget(page_size_lbl) row.addWidget(page_size_lbl)
row.addWidget(page_size_combo) row.addWidget(page_size_combo)
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
# DF-006 follow-up: trimming to a page size alone silently dropped
# every row past it with no way back to see them — prev/next
# buttons plus a "trang X/Y" indicator make the rest reachable.
page_prev_btn = QPushButton()
page_prev_btn.setIcon(icon("chevron-left"))
page_prev_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_prev_btn, "monitoring.page_prev")
page_next_btn = QPushButton()
page_next_btn.setIcon(icon("chevron-right"))
page_next_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_next_btn, "monitoring.page_next")
page_indicator_lbl = QLabel()
def _refresh_pager(cur: int = None, total: int = None) -> None:
if cur is None or total is None:
cur, total = table.current_page(), table.page_count()
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
page_prev_btn.setEnabled(cur > 0)
page_next_btn.setEnabled(cur < total - 1)
page_prev_btn.clicked.connect(table.prev_page)
page_next_btn.clicked.connect(table.next_page)
table.page_changed.connect(_refresh_pager)
_refresh_pager()
row.addWidget(page_prev_btn)
row.addWidget(page_indicator_lbl)
row.addWidget(page_next_btn)
parts.update(
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
lay.addLayout(row) lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn) parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -34,7 +34,6 @@ class ActionLogsTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"] self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -48,7 +47,6 @@ class ActionLogsTab(QWidget):
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label")) self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
-2
View File
@@ -34,7 +34,6 @@ class McpTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"] self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -48,7 +47,6 @@ class McpTab(QWidget):
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label")) self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -40,7 +40,6 @@ class SecurityEventsTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"] self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -54,7 +53,6 @@ class SecurityEventsTab(QWidget):
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label")) self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
+10 -16
View File
@@ -19,14 +19,6 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
set "VENV=%APPHOME%\venv" set "VENV=%APPHOME%\venv"
set "LAUNCHER=%APPHOME%\launcher" set "LAUNCHER=%APPHOME%\launcher"
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
rem lai cua so truoc khi in, de thong bao van doc duoc.
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
rem -------------------------------------------------------------------------- rem --------------------------------------------------------------------------
rem 1. Chon trinh thong dich rem 1. Chon trinh thong dich
rem rem
@@ -46,7 +38,6 @@ if exist "%VENV%\Scripts\python.exe" (
) )
if not defined RUNPY ( if not defined RUNPY (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã. echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
echo. echo.
@@ -60,7 +51,6 @@ rem biet la phai chay install.bat.
if not exist "%VENV%\Scripts\python.exe" ( if not exist "%VENV%\Scripts\python.exe" (
!RUNPY! -c "import PySide6" >nul 2>&1 !RUNPY! -c "import PySide6" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã. echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
echo. echo.
@@ -104,7 +94,6 @@ if /I "%REPO_NAME%"=="cowork_local" (
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1 if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1 mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LOI] Khong tao duoc lien ket thu muc: echo [LOI] Khong tao duoc lien ket thu muc:
echo "!PKGPATH!\cowork_local" -> "%REPO%" echo "!PKGPATH!\cowork_local" -> "%REPO%"
@@ -123,7 +112,6 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
rem ("'cowork_local' is a package and cannot be directly executed"). rem ("'cowork_local' is a package and cannot be directly executed").
if not exist "!PKGPATH!\cowork_local\__main__.py" ( if not exist "!PKGPATH!\cowork_local\__main__.py" (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan: echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
echo "!PKGPATH!" echo "!PKGPATH!"
@@ -151,12 +139,18 @@ set "PYTHONIOENCODING=utf-8"
cd /d "%REPO%" cd /d "%REPO%"
rem -------------------------------------------------------------------------- rem --------------------------------------------------------------------------
rem 4. Chay app rem 4. An cua so console trong luc chay
rem rem
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
rem thong bao loi ben duoi van doc duoc. rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
rem -------------------------------------------------------------------------- rem --------------------------------------------------------------------------
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
!RUNPY! -m cowork_local %* !RUNPY! -m cowork_local %*
set "RC=%ERRORLEVEL%" set "RC=%ERRORLEVEL%"
-55
View File
@@ -1,55 +0,0 @@
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
trở lại. Test này chốt cả nguồn sự thật (tuple
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.core.co4e import (
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
)
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def test_only_running_status_is_locked() -> None:
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
thực sự chạy (STEP_RUNNING) mới bị khoá."""
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
assert STEP_DONE not in _LOCKED_NODE_STATUSES
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
panel = StepConfigPanel()
panel.setEnabled(True) # panel starts disabled until a step is loaded
panel.set_locked(True)
assert not panel.label_edit.isEnabled()
assert not panel.instructions_edit.isEnabled()
assert not panel.model_combo.isEnabled()
panel.set_locked(False)
assert panel.label_edit.isEnabled()
assert panel.instructions_edit.isEnabled()
assert panel.model_combo.isEnabled()
+18 -88
View File
@@ -1,11 +1,9 @@
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next """DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
pagination: EventTable's page-size/page-index state page-size state (presentation/monitoring/shared/event_table.py) and its
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py). No dedicated test existed for this before — the design called for a
user-visible/choosable item-per-page control, and this exercises it end to
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on end (combo selection -> EventTable actually re-trimming its rows)."""
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
it with no way back); this exercises the real paging end to end."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -34,62 +32,19 @@ def _events(n: int):
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)] "name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
def test_page_size_options_are_5_10_20_50_100() -> None: def test_default_page_size_matches_old_max_rows(qapp) -> None:
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
def test_default_page_size(qapp) -> None:
table = EventTable() table = EventTable()
assert table.page_size() == 20 assert table.page_size() == 300
table.set_events(_events(45)) table.set_events(_events(500))
assert table.rowCount() == 20 assert table.rowCount() == 300
assert table.page_count() == 3
assert table.current_page() == 0
def test_set_page_size_resets_to_first_page(qapp) -> None: def test_set_page_size_retrims_without_reloading(qapp) -> None:
table = EventTable() table = EventTable()
table.set_events(_events(45)) table.set_events(_events(500))
table.next_page()
assert table.current_page() == 1
table.set_page_size(50) table.set_page_size(50)
assert table.page_size() == 50 assert table.page_size() == 50
assert table.current_page() == 0 assert table.rowCount() == 50
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.set_page_size(20)
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 1
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 2
assert table.rowCount() == 5 # last page: remainder
table.next_page() # already on last page — stays put
assert table.current_page() == 2
table.prev_page()
assert table.current_page() == 1
table.prev_page()
table.prev_page() # already on first page — stays put
assert table.current_page() == 0
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
table = EventTable()
seen = []
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
table.set_events(_events(45))
table.set_page_size(20)
table.next_page()
assert seen[-1] == (1, 3)
def test_page_size_combo_is_only_added_when_requested(qapp) -> None: def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
@@ -97,44 +52,19 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
table = EventTable() table = EventTable()
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False) parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
assert "page_size_combo" not in parts assert "page_size_combo" not in parts
assert "page_prev_btn" not in parts
def test_page_size_combo_changes_the_table(qapp) -> None: def test_page_size_combo_changes_the_table(qapp) -> None:
page = QWidget() page = QWidget()
table = EventTable() table = EventTable()
table.set_events(_events(45)) table.set_events(_events(500))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True) parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
combo = parts["page_size_combo"] combo = parts["page_size_combo"]
assert combo.count() == len(PAGE_SIZE_OPTIONS) assert combo.count() == len(PAGE_SIZE_OPTIONS)
assert combo.currentData() == 20 # matches EventTable's current page_size assert combo.currentData() == 300 # matches EventTable's current page_size
idx = PAGE_SIZE_OPTIONS.index(10) idx = PAGE_SIZE_OPTIONS.index(50)
combo.setCurrentIndex(idx) combo.setCurrentIndex(idx)
assert table.page_size() == 10 assert table.page_size() == 50
assert table.rowCount() == 10 assert table.rowCount() == 50
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
table.set_page_size(20)
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
indicator = parts["page_indicator_label"]
assert not prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 1/3"
next_btn.click()
assert prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 2/3"
next_btn.click()
assert prev_btn.isEnabled()
assert not next_btn.isEnabled()
assert indicator.text() == "Trang 3/3"