feat: add deepseek-harness workbench plugin and console diagnosis API
- console: GET /api/workflows/{id}, /runs/{run_id}, /runs/{run_id}/diagnosis
(journal trace + sanitized bounded log tails, workflow/run pairing enforced)
- workbench/: dsh 宿主插件(7 个工作流工具、中文系统提示词、失败监控器、
本机回环桥接服务)+ 侧边栏面板客户端包(sidebar.footer.action 与
shell.overlay 追加插槽)+ 降级独立面板 + 一键启动脚本
- adapters/browser: 收敛 looks_like_login_url 到共享层,修复
jd_main_image_collector 对 gyxx_flow.accounts 的越层导入
- tests: 新端点覆盖;replay_policy 断言对齐已迁移的 catalog(repeatable)
This commit is contained in:
@@ -89,6 +89,8 @@ from gyxx_flow.ops import RunIndex, RunRecord
|
||||
CONSOLE_TOKEN_ENV = "GYXX_CONSOLE_TOKEN"
|
||||
MAX_REQUEST_BYTES = 64 * 1024
|
||||
MAX_ERROR_CHARS = 4_000
|
||||
MAX_DIAGNOSIS_LOG_FILES = 5
|
||||
MAX_DIAGNOSIS_LOG_READ_BYTES = 262_144
|
||||
MAX_ACTIVE_RUNS = 4
|
||||
MAX_TRACKED_OPERATIONS = 128
|
||||
PROCESS_TERMINATE_TIMEOUT_SECONDS = 5.0
|
||||
@@ -102,6 +104,7 @@ MAX_NOTIFICATION_LOOKUP_PROOFS = 1024
|
||||
|
||||
_OPERATION_ID = re.compile(r"^op-[0-9a-f]{16}$")
|
||||
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_SAFE_APP_PROFILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
_FEISHU_OPEN_ID = re.compile(r"^ou_[A-Za-z0-9_-]{8,128}$")
|
||||
_FEISHU_APP_ID = re.compile(r"^cli_[A-Za-z0-9_-]{6,128}$")
|
||||
@@ -1807,6 +1810,68 @@ class WorkflowConsoleService:
|
||||
],
|
||||
}
|
||||
|
||||
def workflow_detail(self, workflow_id: str) -> dict[str, object]:
|
||||
"""Return the overview projection for a single workflow."""
|
||||
|
||||
overview = self.overview()
|
||||
workflow = next(
|
||||
(
|
||||
item
|
||||
for item in overview["workflows"]
|
||||
if item["id"] == workflow_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if workflow is None:
|
||||
raise ConsoleNotFoundError("工作流不存在")
|
||||
payload: dict[str, object] = {"workflow": workflow}
|
||||
schedule_revision = overview.get("schedule_revision")
|
||||
if isinstance(schedule_revision, str):
|
||||
payload["schedule_revision"] = schedule_revision
|
||||
warnings = overview.get("warnings")
|
||||
if warnings:
|
||||
payload["warnings"] = warnings
|
||||
return payload
|
||||
|
||||
def run_detail(self, workflow_id: str, run_id: str) -> dict[str, object]:
|
||||
"""Return one indexed run with its journal step details."""
|
||||
|
||||
record = self._run_record_for(workflow_id, run_id)
|
||||
return {
|
||||
"workflow_id": workflow_id,
|
||||
"run": _run_payload(record, data_root=self.settings.data_root),
|
||||
}
|
||||
|
||||
def run_diagnosis(self, workflow_id: str, run_id: str) -> dict[str, object]:
|
||||
"""Bundle one run's journal, trace, and sanitized log tails for agents."""
|
||||
|
||||
record = self._run_record_for(workflow_id, run_id)
|
||||
journal = _journal_trace_payload(record, data_root=self.settings.data_root)
|
||||
return {
|
||||
"workflow_id": workflow_id,
|
||||
"run": _run_payload(record, data_root=self.settings.data_root),
|
||||
"journal": journal,
|
||||
"logs": _diagnosis_logs(
|
||||
record,
|
||||
journal,
|
||||
data_root=self.settings.data_root,
|
||||
),
|
||||
}
|
||||
|
||||
def _run_record_for(self, workflow_id: str, run_id: str) -> RunRecord:
|
||||
catalog, _registered = self._catalog()
|
||||
if workflow_id not in {item.workflow_id for item in catalog.workflows}:
|
||||
raise ConsoleNotFoundError("工作流不存在")
|
||||
if not _SAFE_RUN_ID.fullmatch(run_id):
|
||||
raise ConsoleNotFoundError("运行记录不存在")
|
||||
try:
|
||||
record = RunIndex(self.settings.data_root).get(run_id)
|
||||
except ValueError as exc:
|
||||
raise ConsoleRequestError("运行索引中存在损坏记录") from exc
|
||||
if record is None or record.workflow_id != workflow_id:
|
||||
raise ConsoleNotFoundError("运行记录不存在")
|
||||
return record
|
||||
|
||||
def daily_summary(
|
||||
self,
|
||||
report_date: str | None = None,
|
||||
@@ -2849,6 +2914,136 @@ def _journal_steps(data_root: Path, record: RunRecord) -> list[dict[str, object]
|
||||
return steps
|
||||
|
||||
|
||||
def _journal_trace_payload(
|
||||
record: RunRecord,
|
||||
*,
|
||||
data_root: Path,
|
||||
) -> dict[str, object]:
|
||||
path = (
|
||||
DataLayout(data_root).run_dir(
|
||||
record.workflow_id,
|
||||
record.business_date,
|
||||
record.run_id,
|
||||
)
|
||||
/ "run.json"
|
||||
)
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return {"path": None, "mode": None, "trace": None}
|
||||
if not isinstance(payload, dict):
|
||||
return {"path": None, "mode": None, "trace": None}
|
||||
trace = payload.get("trace")
|
||||
return {
|
||||
"path": path.relative_to(data_root).as_posix(),
|
||||
"mode": payload.get("mode"),
|
||||
"trace": trace if isinstance(trace, dict) else None,
|
||||
}
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read_log_tail(path: Path) -> str | None:
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with path.open("rb") as stream:
|
||||
if size > MAX_DIAGNOSIS_LOG_READ_BYTES:
|
||||
stream.seek(-MAX_DIAGNOSIS_LOG_READ_BYTES, 2)
|
||||
raw = stream.read()
|
||||
except OSError:
|
||||
return None
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
if size > MAX_DIAGNOSIS_LOG_READ_BYTES:
|
||||
newline = text.find("\n")
|
||||
if newline != -1:
|
||||
text = text[newline + 1 :]
|
||||
if not text.strip():
|
||||
return None
|
||||
return _sanitize_error(text)
|
||||
|
||||
|
||||
def _console_logs_in_window(
|
||||
console_log_root: Path,
|
||||
record: RunRecord,
|
||||
) -> list[Path]:
|
||||
try:
|
||||
started = datetime.fromisoformat(record.started_at)
|
||||
except ValueError:
|
||||
return []
|
||||
ended: datetime
|
||||
if record.ended_at is not None:
|
||||
try:
|
||||
ended = datetime.fromisoformat(record.ended_at)
|
||||
except ValueError:
|
||||
ended = datetime.now(timezone.utc)
|
||||
else:
|
||||
ended = datetime.now(timezone.utc)
|
||||
matches: list[Path] = []
|
||||
for path in console_log_root.glob(f"{record.workflow_id}-*.log"):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
||||
except OSError:
|
||||
continue
|
||||
if mtime < started - timedelta(minutes=10):
|
||||
continue
|
||||
if mtime > ended + timedelta(minutes=10):
|
||||
continue
|
||||
matches.append(path)
|
||||
matches.sort(key=lambda item: item.stat().st_mtime, reverse=True)
|
||||
return matches
|
||||
|
||||
|
||||
def _diagnosis_logs(
|
||||
record: RunRecord,
|
||||
journal: dict[str, object],
|
||||
*,
|
||||
data_root: Path,
|
||||
) -> list[dict[str, object]]:
|
||||
root = data_root.resolve()
|
||||
candidates: list[Path] = []
|
||||
trace = journal.get("trace")
|
||||
paths = trace.get("paths") if isinstance(trace, dict) else None
|
||||
log_rel = paths.get("log") if isinstance(paths, dict) else None
|
||||
if isinstance(log_rel, str) and log_rel:
|
||||
log_dir = (root / log_rel).resolve()
|
||||
if _is_within(log_dir, root) and log_dir.is_dir():
|
||||
candidates.extend(
|
||||
sorted(
|
||||
log_dir.glob("*.log"),
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
console_log_root = (root / "logs" / "console").resolve()
|
||||
if console_log_root.is_dir():
|
||||
candidates.extend(_console_logs_in_window(console_log_root, record))
|
||||
logs: list[dict[str, object]] = []
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve()
|
||||
if resolved in seen or not _is_within(resolved, root):
|
||||
continue
|
||||
seen.add(resolved)
|
||||
tail = _read_log_tail(resolved)
|
||||
if tail is None:
|
||||
continue
|
||||
logs.append(
|
||||
{
|
||||
"path": resolved.relative_to(root).as_posix(),
|
||||
"tail": tail,
|
||||
}
|
||||
)
|
||||
if len(logs) >= MAX_DIAGNOSIS_LOG_FILES:
|
||||
break
|
||||
return logs
|
||||
|
||||
|
||||
def _sanitize_error(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -3429,10 +3624,15 @@ class WorkflowConsoleRequestHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
runs_match = re.fullmatch(r"/api/workflows/([^/]+)/runs", path)
|
||||
workflow_match = re.fullmatch(r"/api/workflows/([^/]+)", path)
|
||||
cancel_match = re.fullmatch(
|
||||
r"/api/workflows/([^/]+)/runs/([^/]+)",
|
||||
path,
|
||||
)
|
||||
diagnosis_match = re.fullmatch(
|
||||
r"/api/workflows/([^/]+)/runs/([^/]+)/diagnosis",
|
||||
path,
|
||||
)
|
||||
scheduled_cancel_match = re.fullmatch(
|
||||
r"/api/workflows/([^/]+)/scheduled-run",
|
||||
path,
|
||||
@@ -3455,6 +3655,34 @@ class WorkflowConsoleRequestHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
self._json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if method == "GET" and diagnosis_match:
|
||||
self._require_api_access(mutation=False)
|
||||
workflow_id = unquote(diagnosis_match.group(1))
|
||||
run_id = unquote(diagnosis_match.group(2))
|
||||
payload = self.console_server.console_service.run_diagnosis(
|
||||
workflow_id,
|
||||
run_id,
|
||||
)
|
||||
self._json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if method == "GET" and cancel_match:
|
||||
self._require_api_access(mutation=False)
|
||||
workflow_id = unquote(cancel_match.group(1))
|
||||
run_id = unquote(cancel_match.group(2))
|
||||
payload = self.console_server.console_service.run_detail(
|
||||
workflow_id,
|
||||
run_id,
|
||||
)
|
||||
self._json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if method == "GET" and workflow_match:
|
||||
self._require_api_access(mutation=False)
|
||||
workflow_id = unquote(workflow_match.group(1))
|
||||
payload = self.console_server.console_service.workflow_detail(
|
||||
workflow_id,
|
||||
)
|
||||
self._json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if method == "PUT" and schedule_match:
|
||||
self._require_api_access(mutation=True)
|
||||
workflow_id = unquote(schedule_match.group(1))
|
||||
|
||||
Reference in New Issue
Block a user