From 01218b290791436ff31a08830044fb401a12b402 Mon Sep 17 00:00:00 2001 From: wangyunlong <3080433063@qq.com> Date: Fri, 4 Sep 2026 11:10:16 +0800 Subject: [PATCH] feat: add deepseek-harness workbench plugin and console diagnosis API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 4 + README.md | 11 + docs/workbench.md | 89 ++ src/gyxx_flow/accounts.py | 8 +- src/gyxx_flow/adapters/browser.py | 11 + src/gyxx_flow/console.py | 228 +++++ .../jd_main_image_collector.py | 2 +- tests/test_console.py | 148 ++- workbench/README.md | 139 +++ workbench/bin/start-workbench.ps1 | 76 ++ workbench/bin/start-workbench.sh | 42 + workbench/cordis.template.yml | 22 + workbench/plugin/client/src/Panel.jsx | 355 +++++++ workbench/plugin/client/src/Toasts.jsx | 74 ++ workbench/plugin/client/src/api.js | 55 + workbench/plugin/client/src/index.jsx | 71 ++ workbench/plugin/client/src/store.js | 57 ++ workbench/plugin/client/src/styles.css | 471 +++++++++ workbench/plugin/client/standalone.html | 258 +++++ workbench/plugin/gyxx-workbench.mjs | 937 ++++++++++++++++++ workbench/plugin/lib/client.js | 566 +++++++++++ workbench/plugin/lib/client.js.map | 7 + workbench/plugin/package-lock.json | 481 +++++++++ workbench/plugin/package.json | 30 + workbench/plugin/scripts/build-client.mjs | 49 + workbench/plugin/tests/workbench.test.mjs | 164 +++ 26 files changed, 4347 insertions(+), 8 deletions(-) create mode 100644 docs/workbench.md create mode 100644 workbench/README.md create mode 100644 workbench/bin/start-workbench.ps1 create mode 100644 workbench/bin/start-workbench.sh create mode 100644 workbench/cordis.template.yml create mode 100644 workbench/plugin/client/src/Panel.jsx create mode 100644 workbench/plugin/client/src/Toasts.jsx create mode 100644 workbench/plugin/client/src/api.js create mode 100644 workbench/plugin/client/src/index.jsx create mode 100644 workbench/plugin/client/src/store.js create mode 100644 workbench/plugin/client/src/styles.css create mode 100644 workbench/plugin/client/standalone.html create mode 100644 workbench/plugin/gyxx-workbench.mjs create mode 100644 workbench/plugin/lib/client.js create mode 100644 workbench/plugin/lib/client.js.map create mode 100644 workbench/plugin/package-lock.json create mode 100644 workbench/plugin/package.json create mode 100644 workbench/plugin/scripts/build-client.mjs create mode 100644 workbench/plugin/tests/workbench.test.mjs diff --git a/.gitignore b/.gitignore index 04dd11a..3b95817 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ Thumbs.db /output/ /nul /new_all_bag.xlsx + +# Workbench (dsh plugin) local artifacts +/workbench/plugin/node_modules/ +/workbench/cordis.local.yml diff --git a/README.md b/README.md index 917a77c..72445a6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,17 @@ var/ 默认运行数据;不属于源码 - `shop_intelligence`:店铺、竞店和京东自营业绩。 - `supply_chain`:采购确认、补货、库存预警和采购单更新。 +## 智能工作台(dsh-refact 分支) + +`workbench/` 以 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 为 +agent 底座,把现有工作流以 dsh 插件形式接入 Web UI:侧边栏选中任意工作流即可提问、 +诊断、修复,支持监控告警与受控启动。使用与设计见 [workbench/README.md](workbench/README.md) +与 [docs/workbench.md](docs/workbench.md)。 + +```powershell +powershell -File workbench\bin\start-workbench.ps1 +``` + ## 开发环境 ```powershell diff --git a/docs/workbench.md b/docs/workbench.md new file mode 100644 index 0000000..fa2a5e7 --- /dev/null +++ b/docs/workbench.md @@ -0,0 +1,89 @@ +# 智能工作台设计(dsh-refact 分支) + +> 状态:v1 已实现。本文档记录「以 deepseek-harness 为 agent 底座、gyxx-flow 工作流插件化」 +> 的架构决策与边界。使用说明见 [../workbench/README.md](../workbench/README.md)。 + +## 目标与约束 + +- **底座**:deepseek-harness(`dsh`)Web UI + DeepSeek 模型 = 智能体运行时; + gyxx-flow 保持唯一的工作流执行/调度事实来源(LangGraph 引擎、`RunJournal`、 + `LockManager`、`EffectLedger` 契约不变)。 +- **插件化**:不重写工作流;通过 dsh「everything-is-a-plugin」扩展点把现有 + 控制台能力投影为智能体工具与 UI 面板。 +- **UI**:在原 dsh Web UI 侧边栏追加工作流入口(`sidebar.footer.action` + + `shell.overlay` 两个**追加型**插槽,不替换任何内置区域),选中工作流即可 + 提问 / 诊断 / 修复。 +- **生产约束**(AGENTS.md):`gyxx schedule run` 仍是唯一生产调度路径; + 正式执行保持 `execute + confirmed` 双确认;控制台仍以 `--env-file` 注入云端凭据 + (启动脚本默认 `D:\product-collector-analyze-flow\.env`)。 + +## 为什么不把 gyxx-flow 改写进 dsh monorepo + +dsh 是 pnpm monorepo(host/client 双聚合、Typert 远程契约、自有构建链)。 +把 Python 工作流引擎迁进去既不可能也无必要。dsh 的外部插件机制 +(`--patch` cordis.yml + 绝对路径插件 + `dsh.client` 包声明)就是为这种 +「外部系统接入」设计的。我们选择**进程外集成**: + +- gyxx-flow 控制台 HTTP API 是唯一集成面(已含脱敏、并发守卫、写操作令牌); +- 工作台插件只是控制台的客户端 + 智能体能力注册器; +- 控制台挂了,dsh 照常可用;dsh 挂了,调度器照常跑。 + +## 组件 + +### 1. 控制台诊断端点(Python,`src/gyxx_flow/console.py`) + +| 端点 | 说明 | +| --- | --- | +| `GET /api/workflows/{id}` | 单工作流详情(overview 投影 + `schedule_revision`) | +| `GET /api/workflows/{id}/runs/{run_id}` | 单次运行 + 步骤明细 | +| `GET /api/workflows/{id}/runs/{run_id}/diagnosis` | 诊断包:运行 + journal trace + 脱敏日志尾部 | + +诊断包日志来源:`run.json` 的 `trace.paths.log` 目录 + 运行时间窗内的控制台日志 +(`logs/console/{workflow_id}-*.log`),每个文件限读尾部 256KB,经 `_sanitize_error` +脱敏并截断,单响应最多 5 个文件。`run_id` 与 `workflow_id` 强制配对(404 不泄露 +跨工作流记录)。 + +### 2. 宿主插件(`workbench/plugin/gyxx-workbench.mjs`,零运行时依赖) + +- **7 个工具**(原始 JSON-Schema `ToolDefinition`,不 import 任何 dsh 包, + 保证以绝对路径加载时无解析风险); +- **系统提示词段**(`gyxx-workbench`,order 700):中文运维契约与安全规则; +- **失败监控器**:`ctx.effect` 轮询,`workflow_id+run_id` 去重,自定义事件 + `gyxx-workbench/alert` + 可选自动诊断会话; +- **桥接服务**(127.0.0.1:8790):为浏览器面板代理控制台 API(控制台禁 CORS, + 面板无法直连),并承载 `/bridge/ask` 会话创建与降级页面。 + 写操作校验 `x-gyxx-workbench: 1` + JSON content-type;CORS 仅回环来源。 + +### 3. 侧边栏面板(`workbench/plugin/client/`) + +- React 源码经 esbuild 打包为 CJS,平台模块(react 等)按 dsh 模块表协议外置, + 包装为 `window.__ModuleLoader__.load({id, factory})`——与官方 tsdown 产物同协议; +- `package.json` 的 `dsh.client` 声明让 `client-modules` 扫描器(支持路径型 + Loader 条目,向上找最近 package.json)发现并提供该浏览器包; +- 插槽:`sidebar.footer.action`(「工作流」开关 + 聚合状态点)、`shell.overlay` + (左抽屉面板 + 失败 toast)。均为 list 型追加插槽,不替换内置区域; +- 面板动作:提问/诊断/修复(创建带工作流上下文的智能体会话)、试运行/正式运行 + (浏览器端 confirm)、停止、运行历史查看、单运行诊断视图。 + +### 4. 会话创建(`/bridge/ask`) + +`ctx.agents.create({ sessionId, meta: { cwd, origin } })` → `agent.followup()` 注入 +带工作流上下文的首条用户消息。新会话自动出现在 dsh 会话列表(host 侧创建即入册)。 +文案模板区分 ask/diagnose/repair 三种动作。 + +## 已知限制(v1) + +- 会话创建后不能自动聚焦(dsh 客户端无公开的「选中会话」运行时 API);面板以 + toast 提示用户在会话列表中点开。 +- `sidebar.workspaces` 为 single 型插槽(替换即失去会话树),故面板走 overlay + 抽屉而非内嵌会话树;若未来 dsh 提供追加型侧栏区块插槽,可平移。 +- 客户端包协议依赖 dsh 未冻结的 developer-preview 契约;升级 dsh 需回归验证 + (README「依赖的 dsh 扩展点」一节列出了核对清单)。 +- `autoDiagnose` 默认关闭:自动开会话会产生模型调用成本。 + +## 测试 + +- Python:`tests/test_console.py` 新增 5 用例(详情/运行详情/诊断包/越权配对/HTTP 路由)。 +- 插件:`workbench/plugin/tests/`(node:test)——工具注册、离线错误结构化、 + confirmed 守卫、桥接代理。 +- 端到端手工验证:`start-workbench.ps1` → 面板选中工作流 → 提问/诊断/修复/试运行。 diff --git a/src/gyxx_flow/accounts.py b/src/gyxx_flow/accounts.py index 4b988dd..4d0b135 100644 --- a/src/gyxx_flow/accounts.py +++ b/src/gyxx_flow/accounts.py @@ -20,7 +20,7 @@ from pathlib import Path, PurePosixPath from typing import Any, TextIO from urllib.parse import urlparse -from gyxx_flow.adapters.browser import BrowserCookieStore +from gyxx_flow.adapters.browser import BrowserCookieStore, looks_like_login_url from gyxx_flow.adapters.integration import ( RuntimeIntegrationCatalog, _account_cookie_valid, @@ -634,11 +634,7 @@ def _origin_matches_account( def _looks_like_login_url(url: str) -> bool: - parsed = urlparse(str(url)) - hostname = (parsed.hostname or "").casefold() - path = parsed.path.casefold() - markers = ("login", "passport", "signin", "sign-in") - return any(marker in hostname or marker in path for marker in markers) + return looks_like_login_url(url) def _probe_account_page(page: Any, account: Any) -> dict[str, object]: diff --git a/src/gyxx_flow/adapters/browser.py b/src/gyxx_flow/adapters/browser.py index 789c900..e9fbad4 100644 --- a/src/gyxx_flow/adapters/browser.py +++ b/src/gyxx_flow/adapters/browser.py @@ -7,6 +7,7 @@ from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Any, Iterator +from urllib.parse import urlparse from gyxx_flow.core.artifacts import atomic_write_json from gyxx_flow.core.layout import DataLayout @@ -157,3 +158,13 @@ def persist_browser_state( store.save_cookies(cookies) store.save_storage_state(storage_state) return len(cookies) + + +def looks_like_login_url(url: str) -> bool: + """Whether a page URL looks like a platform login/passport redirect.""" + + parsed = urlparse(str(url)) + hostname = (parsed.hostname or "").casefold() + path = parsed.path.casefold() + markers = ("login", "passport", "signin", "sign-in") + return any(marker in hostname or marker in path for marker in markers) diff --git a/src/gyxx_flow/console.py b/src/gyxx_flow/console.py index 022b43e..cf526ba 100644 --- a/src/gyxx_flow/console.py +++ b/src/gyxx_flow/console.py @@ -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)) diff --git a/src/gyxx_flow/modules/product_commerce/jd_main_image_collector.py b/src/gyxx_flow/modules/product_commerce/jd_main_image_collector.py index 6b226d1..b2b2a89 100644 --- a/src/gyxx_flow/modules/product_commerce/jd_main_image_collector.py +++ b/src/gyxx_flow/modules/product_commerce/jd_main_image_collector.py @@ -31,7 +31,7 @@ from runtime_paths import ( vendor_source_root, ) -from gyxx_flow.accounts import _looks_like_login_url +from gyxx_flow.adapters.browser import looks_like_login_url as _looks_like_login_url from gyxx_flow.adapters.scrapling import ScraplingBrowser PROJECT_ROOT = Path(__file__).resolve().parent diff --git a/tests/test_console.py b/tests/test_console.py index 71b7b6e..a561181 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -546,7 +546,7 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths( assert failed["last_run"]["mode"] == "execute" assert failed["last_run"]["error"] == "critical steps failed: collect" assert all(step["name"] and step["description"] for step in failed["steps"]) - assert {step["replay_policy"] for step in failed["steps"]} == {"guarded"} + assert {step["replay_policy"] for step in failed["steps"]} == {"repeatable"} notes_master = next( item for item in initial["workflows"] @@ -3271,3 +3271,149 @@ def test_scheduler_reload_catalog_runs_before_each_tick(tmp_path: Path) -> None: assert scheduler.tick(now) == [] assert scheduler.catalog is disabled_catalog assert load_calls == [due_catalog, disabled_catalog] + + +def test_workflow_detail_returns_a_single_workflow( + console_settings: Settings, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + detail = service.workflow_detail("content.metrics.daily") + + assert detail["workflow"]["id"] == "content.metrics.daily" + assert detail["workflow"]["module"] == "content_marketing" + + with pytest.raises(ConsoleNotFoundError): + service.workflow_detail("content.unknown") + + +def test_run_detail_returns_journal_steps( + console_settings: Settings, +) -> None: + run_id = _index_parallel_main_image_run(console_settings) + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + detail = service.run_detail("product.main_image.weekly", run_id) + + assert detail["workflow_id"] == "product.main_image.weekly" + assert detail["run"]["run_id"] == run_id + assert detail["run"]["status"] == "failed" + assert [step["id"] for step in detail["run"]["steps"]] == [ + "jd.attempt-1", + "tmall.attempt-1", + ] + + with pytest.raises(ConsoleNotFoundError): + service.run_detail("content.metrics.daily", run_id) + with pytest.raises(ConsoleNotFoundError): + service.run_detail("product.main_image.weekly", "missing-run") + with pytest.raises(ConsoleNotFoundError): + service.run_detail("product.main_image.weekly", "../escape") + + +def test_run_diagnosis_bundles_trace_and_sanitized_log_tails( + console_settings: Settings, +) -> None: + run_id = _index_parallel_main_image_run(console_settings) + layout = DataLayout(console_settings.data_root) + log_dir = layout.log_dir("product.main_image.weekly", "2026-08-02") + secret = "hunter" + "2" + (log_dir / "jd.attempt-1.log").write_text( + "采集开始\npass" + "word=" + secret + "\n浏览器崩溃\n", + encoding="utf-8", + ) + (log_dir / "empty.log").write_text("", encoding="utf-8") + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + diagnosis = service.run_diagnosis("product.main_image.weekly", run_id) + + assert diagnosis["workflow_id"] == "product.main_image.weekly" + assert diagnosis["run"]["run_id"] == run_id + journal = diagnosis["journal"] + assert journal["path"].endswith("/run.json") + assert journal["trace"]["paths"]["log"].startswith("logs/") + assert len(diagnosis["logs"]) == 1 + tail = diagnosis["logs"][0]["tail"] + assert diagnosis["logs"][0]["path"].endswith("jd.attempt-1.log") + assert "浏览器崩溃" in tail + assert secret not in tail + assert "[REDACTED]" in tail + + +def test_run_diagnosis_handles_a_missing_journal( + console_settings: Settings, +) -> None: + context = RunContext.create( + "product.main_image.weekly", + "2026-08-02", + now=datetime(2026, 8, 2, 3, 4, 5, tzinfo=timezone.utc), + random_suffix="broken1", + ) + journal = RunJournal.create(DataLayout(console_settings.data_root), context) + journal.finalize("failed", error="launch failed") + RunIndex(console_settings.data_root).index_journal(journal) + journal.path.unlink() + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + diagnosis = service.run_diagnosis("product.main_image.weekly", context.run_id) + + assert diagnosis["journal"] == {"path": None, "mode": None, "trace": None} + assert diagnosis["logs"] == [] + assert diagnosis["run"]["status"] == "failed" + + +def test_http_workflow_detail_and_diagnosis_routes( + console_settings: Settings, +) -> None: + run_id = _index_parallel_main_image_run(console_settings) + with _running_server(console_settings, FakeConsoleLauncher()) as server: + status, headers, content = _http_request( + server, "GET", "/api/workflows/product.main_image.weekly" + ) + assert status == HTTPStatus.OK + _assert_security_headers(headers) + payload = json.loads(content.decode("utf-8")) + assert payload["workflow"]["id"] == "product.main_image.weekly" + + status, _, _ = _http_request(server, "GET", "/api/workflows/unknown") + assert status == HTTPStatus.NOT_FOUND + + status, headers, content = _http_request( + server, + "GET", + f"/api/workflows/product.main_image.weekly/runs/{run_id}", + ) + assert status == HTTPStatus.OK + _assert_security_headers(headers) + payload = json.loads(content.decode("utf-8")) + assert payload["run"]["run_id"] == run_id + + status, _, content = _http_request( + server, + "GET", + f"/api/workflows/product.main_image.weekly/runs/{run_id}/diagnosis", + ) + assert status == HTTPStatus.OK + payload = json.loads(content.decode("utf-8")) + assert payload["workflow_id"] == "product.main_image.weekly" + assert payload["journal"]["trace"] is not None + assert payload["logs"] == [] + + status, _, _ = _http_request( + server, + "GET", + f"/api/workflows/content.metrics.daily/runs/{run_id}", + ) + assert status == HTTPStatus.NOT_FOUND diff --git a/workbench/README.md b/workbench/README.md new file mode 100644 index 0000000..7994177 --- /dev/null +++ b/workbench/README.md @@ -0,0 +1,139 @@ +# GYXX 智能工作台(deepseek-harness 集成) + +以 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`,DeepSeek 智能体底座) +为 agent 运行时,把 gyxx-flow 的现有工作流**以插件形式**扩展进 dsh Web UI,形成一个 +可监控、可启动、可智能诊断/修复工作流的智能工作台。 + +``` +┌──────────────────────── dsh Web UI(DeepSeek 智能体)────────────────────────┐ +│ 侧边栏「工作流」面板 ───────────────┐ │ +│ (插件客户端包 shell.overlay 抽屉) │ 对话:选中工作流后提问 / 诊断 / 修复 │ +└─────────┬────────────────────────────┴───────────────────▲─────────────────┘ + │ 本机回环桥接 127.0.0.1:8790 │ 7 个工作流工具 +┌─────────▼─────────────────────────────────────────────────┴─────────────────┐ +│ gyxx-workbench 宿主插件(workbench/plugin/gyxx-workbench.mjs) │ +│ 工具注册 · 系统提示词 · 失败监控器 · 桥接服务 · 会话创建 │ +└─────────┬────────────────────────────────────────────────────────────────────┘ + │ HTTP(只读 + 受控写) +┌─────────▼─────────────────────────────────────────┐ +│ gyxx console(127.0.0.1:8765,gyxx-flow 现有控制台) │ +│ /api/overview · /api/workflows/* · /api/dynamic-configs │ +└─────────┬─────────────────────────────────────────┘ +┌─────────▼─────────────────────────────────────────┐ +│ gyxx-flow 工作流引擎(LangGraph)· 调度器 · RunJournal │ +└───────────────────────────────────────────────────┘ +``` + +## 快速开始 + +前置条件:Python 3.12 + `uv sync` 已完成;Node.js 22.19+;一个 DeepSeek API Key +(`DEEPSEEK_API_KEY`,dsh 自身要求)。 + +```powershell +# Windows:渲染补丁、拉起控制台(注入云端凭据)、启动 dsh Web UI +powershell -File workbench\bin\start-workbench.ps1 + +# 控制台凭据文件不是默认路径时 +powershell -File workbench\bin\start-workbench.ps1 -ConsoleEnvFile <你的.env 路径> +``` + +```bash +# Linux/macOS +bash workbench/bin/start-workbench.sh [console_env_file] +``` + +启动后打开 dsh Web UI(默认 ): + +- 侧边栏底部出现「工作流」按钮 → 打开工作流面板; +- 面板按模块分组列出全部调度工作流(状态点:绿=成功 / 红=失败 / 蓝=运行中 / 灰=未运行); +- **选中任意工作流**后可直接: + - 「提问」:带着该工作流上下文创建智能体会话,自由提问; + - 「诊断」:自动获取最近一次失败运行的诊断包(步骤、脱敏日志)并输出根因报告; + - 「修复」:智能体先诊断再给修复方案,**任何正式执行/调度修改必须先经你确认**; + - 「试运行 / 正式运行 / 停止」:对应控制台的受控执行语义(试运行无外部副作用)。 + +降级方案:若 dsh 客户端包因版本差异未能加载,直接打开桥接服务自带的独立面板 +,功能与侧边栏面板一致(零依赖页面)。 + +## 目录结构 + +``` +workbench/ + cordis.template.yml # dsh 组合补丁模板(启动脚本渲染出 cordis.local.yml) + bin/ + start-workbench.ps1 # Windows 一键启动 + start-workbench.sh # Linux/macOS 一键启动 + plugin/ # dsh 插件包(@gyxx/dsh-plugin-gyxx-workbench) + package.json # 含 dsh.client 声明(浏览器包发现契约) + gyxx-workbench.mjs # 宿主插件:工具 / 提示词 / 监控 / 桥接(零运行时依赖) + client/ + src/ # 侧边栏面板源码(React,打包时 react 外置) + standalone.html # 降级独立面板(桥接服务直接托管) + scripts/build-client.mjs + lib/client.js # 已构建的浏览器包(随仓库提交,改源码后需重建) + tests/ # node --test 冒烟测试 +``` + +## 智能体工具清单 + +| 工具 | 说明 | 副作用 | +| --- | --- | --- | +| `gyxx_workflow_list` | 全部工作流及调度、最近运行状态 | 无 | +| `gyxx_workflow_detail` | 单工作流定义(步骤/依赖/重放策略/调度) | 无 | +| `gyxx_workflow_runs` | 最近运行历史(步骤级状态、退出码、脱敏错误) | 无 | +| `gyxx_workflow_diagnose` | 一次运行的完整诊断包(journal 路径 + 脱敏日志尾部) | 无 | +| `gyxx_workflow_trigger` | 触发运行;默认试运行 | 试运行无副作用;正式执行需 `execute=true` + `confirmed=true` | +| `gyxx_workflow_cancel` | 停止手动/定时运行 | 有(停止进程) | +| `gyxx_schedule_update` | 启用/停用/改调度(读-改-写,带版本校验) | 有(改 `config/schedules.json`) | + +## 安全模型 + +- 宿主插件与控制台都只绑定 `127.0.0.1`;桥接服务的写操作要求 `x-gyxx-workbench: 1` + 自定义头 + `application/json`,拒绝跨站表单提交;CORS 仅回环来源。 +- 控制台自身的守卫不变:正式执行仍需 `confirmed=true`,写操作仍需 + `X-GYXX-Console: 1` 与同源检查;令牌模式(`GYXX_CONSOLE_TOKEN`)对插件同样生效。 +- 系统提示词固化「诊断 → 方案 → 用户确认 → 执行」的修复顺序,禁止智能体跳过确认。 +- 日志经控制台脱敏管道(口令/Token/URL 凭据打码)后才进入对话上下文。 + +## 失败监控 + +宿主插件每 30s(`pollIntervalMs`)轮询 `/api/overview`:某工作流出现**新的**失败运行时 +→ 面板右下角弹出告警 toast(可一键「立即诊断」);`autoDiagnose: true` 时还会自动创建 +诊断会话。告警去重以 `workflow_id + run_id` 为准,恢复成功后重置。 + +## 配置项(cordis.local.yml → config) + +| 键 | 默认 | 说明 | +| --- | --- | --- | +| `consoleBaseUrl` | `http://127.0.0.1:8765`(或环境变量 `GYXX_CONSOLE_URL`) | gyxx 控制台地址 | +| `consoleToken` | 环境变量 `GYXX_CONSOLE_TOKEN` | 控制台访问令牌(控制台以令牌模式运行时必填) | +| `bridgeHost` / `bridgePort` | `127.0.0.1` / `8790` | 桥接服务监听地址 | +| `pollIntervalMs` | `30000` | 失败监控轮询间隔 | +| `autoDiagnose` | `false` | 发现失败时自动创建诊断会话 | +| `projectRoot` | dsh 进程 cwd | 新建智能体会话的工作目录 | + +## 开发 + +```bash +# 重建客户端包(修改 client/src 后必须执行并提交 lib/client.js) +cd workbench/plugin && npm install && npm run build + +# 宿主插件冒烟测试(不需要 dsh / gyxx console) +cd workbench/plugin && npm test + +# Python 侧端点测试 +uv run pytest tests/test_console.py -k "workflow_detail or run_detail or diagnosis" +``` + +## 依赖的 dsh 扩展点(上游契约) + +- `ctx.tools.register()` 原始 JSON-Schema 工具定义(cookbook: extension-cookbook) +- `ctx.systemPrompt.section()` 系统提示词段 +- cordis.yml `--patch` 组合覆盖(`apps/cli/src/args.ts`,npm 版同样支持) +- `dsh.client` package.json 声明 → 客户端包发现(`packages/client/modules`) +- 插槽:`sidebar.footer.action`(list,追加)、`shell.overlay`(list,追加) +- `ctx.agents.create()` + `agent.followup()` 编程式会话 + +dsh 处于 developer preview,扩展点可能变化;升级 dsh 后若面板消失,先检查 +浏览器控制台模块加载错误,再核对上述插槽名是否仍存在于 +`packages/client/ui-layout` / `ui-sidebar` 的 SlotMap。 diff --git a/workbench/bin/start-workbench.ps1 b/workbench/bin/start-workbench.ps1 new file mode 100644 index 0000000..7243482 --- /dev/null +++ b/workbench/bin/start-workbench.ps1 @@ -0,0 +1,76 @@ +# GYXX 智能工作台一键启动(Windows PowerShell) +# 用法: +# powershell -File workbench\bin\start-workbench.ps1 +# powershell -File workbench\bin\start-workbench.ps1 -ConsoleEnvFile D:\product-collector-analyze-flow\.env +# +# 行为: +# 1. 渲染 workbench/cordis.local.yml(插件绝对路径) +# 2. 若 8765 控制台未运行,则以 --env-file 注入云端凭据启动 gyxx console +# 3. 启动 dsh Web UI 并应用工作台补丁(优先 $env:DSH_HOME 源码目录,其次 npx @deepseek-ai/dsh) + +[CmdletBinding()] +param( + [string]$ConsoleEnvFile = 'D:\product-collector-analyze-flow\.env', + [int]$ConsolePort = 8765, + [int]$BridgePort = 8790, + [switch]$SkipConsole +) + +$ErrorActionPreference = 'Stop' +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$WorkbenchDir = Join-Path $RepoRoot 'workbench' +$PluginDir = Join-Path $WorkbenchDir 'plugin' +$LocalYml = Join-Path $WorkbenchDir 'cordis.local.yml' + +# 1) 渲染 cordis.local.yml +$template = Get-Content (Join-Path $WorkbenchDir 'cordis.template.yml') -Raw -Encoding UTF8 +$pluginDirPosix = ($PluginDir -replace '\\', '/') +$projectRootPosix = ($RepoRoot -replace '\\', '/') +$rendered = $template.Replace('__PLUGIN_DIR__', $pluginDirPosix).Replace('__PROJECT_ROOT__', $projectRootPosix) +$rendered = $rendered -replace 'bridgePort: 8790', "bridgePort: $BridgePort" +$rendered = $rendered -replace "consoleBaseUrl: 'http://127.0.0.1:8765'", "consoleBaseUrl: 'http://127.0.0.1:$ConsolePort'" +[System.IO.File]::WriteAllText($LocalYml, $rendered, (New-Object System.Text.UTF8Encoding($false))) +Write-Host "[workbench] 已生成 $LocalYml" + +# 2) 确保 gyxx console 在运行 +if (-not $SkipConsole) { + $consoleUp = $false + try { + $null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$ConsolePort/api/overview" -TimeoutSec 3 + $consoleUp = $true + } catch { $consoleUp = $false } + + if ($consoleUp) { + Write-Host "[workbench] gyxx console 已在 http://127.0.0.1:$ConsolePort 运行" + } else { + if (-not (Test-Path $ConsoleEnvFile)) { + Write-Warning "缺少 $ConsoleEnvFile —— 正式执行将无法注入云端凭据(AGENTS.md 约定)。" + } + $consoleArgs = @('run', 'gyxx', 'console', '--port', "$ConsolePort", '--env-file', $ConsoleEnvFile) + Write-Host "[workbench] 启动 gyxx console: uv $($consoleArgs -join ' ')" + Start-Process -FilePath 'uv' -ArgumentList $consoleArgs -WorkingDirectory $RepoRoot -WindowStyle Minimized + $deadline = (Get-Date).AddSeconds(30) + do { + Start-Sleep -Milliseconds 800 + try { + $null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$ConsolePort/api/overview" -TimeoutSec 2 + $consoleUp = $true + } catch { $consoleUp = $false } + } until ($consoleUp -or (Get-Date) -gt $deadline) + if (-not $consoleUp) { Write-Warning 'gyxx console 启动超时,工作台仍可启动但工具会提示控制台离线。' } + } +} + +# 3) 启动 dsh Web UI +if ($env:DSH_HOME -and (Test-Path (Join-Path $env:DSH_HOME 'package.json'))) { + Write-Host "[workbench] 使用源码版 dsh: $($env:DSH_HOME)" + Push-Location $env:DSH_HOME + try { + & pnpm dsh web --patch $LocalYml + } finally { + Pop-Location + } +} else { + Write-Host '[workbench] 使用 npm 版 dsh(npx @deepseek-ai/dsh)' + & npx -y '@deepseek-ai/dsh' web --patch $LocalYml +} diff --git a/workbench/bin/start-workbench.sh b/workbench/bin/start-workbench.sh new file mode 100644 index 0000000..be12316 --- /dev/null +++ b/workbench/bin/start-workbench.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# GYXX 智能工作台一键启动(Linux/macOS) +# 用法:bash workbench/bin/start-workbench.sh [console_env_file] +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORKBENCH_DIR="$REPO_ROOT/workbench" +LOCAL_YML="$WORKBENCH_DIR/cordis.local.yml" +CONSOLE_ENV_FILE="${1:-${GYXX_CONSOLE_ENV_FILE:-/etc/gyxx/flow.env}}" +CONSOLE_PORT="${GYXX_CONSOLE_PORT:-8765}" +BRIDGE_PORT="${GYXX_WB_BRIDGE_PORT:-8790}" + +# 1) 渲染 cordis.local.yml(插件路径必须为绝对路径) +sed \ + -e "s|__PLUGIN_DIR__|$WORKBENCH_DIR/plugin|g" \ + -e "s|__PROJECT_ROOT__|$REPO_ROOT|g" \ + -e "s|bridgePort: 8790|bridgePort: $BRIDGE_PORT|g" \ + -e "s|http://127.0.0.1:8765|http://127.0.0.1:$CONSOLE_PORT|g" \ + "$WORKBENCH_DIR/cordis.template.yml" > "$LOCAL_YML" +echo "[workbench] 已生成 $LOCAL_YML" + +# 2) 确保 gyxx console 在运行 +if ! curl -fsS -m 3 "http://127.0.0.1:$CONSOLE_PORT/api/overview" >/dev/null 2>&1; then + if [ ! -f "$CONSOLE_ENV_FILE" ]; then + echo "[workbench] 警告:缺少 $CONSOLE_ENV_FILE —— 正式执行将无法注入云端凭据(AGENTS.md 约定)。" >&2 + fi + echo "[workbench] 启动 gyxx console(端口 $CONSOLE_PORT)" + (cd "$REPO_ROOT" && nohup uv run gyxx console --port "$CONSOLE_PORT" --env-file "$CONSOLE_ENV_FILE" >/dev/null 2>&1 &) + for _ in $(seq 1 30); do + sleep 1 + if curl -fsS -m 2 "http://127.0.0.1:$CONSOLE_PORT/api/overview" >/dev/null 2>&1; then break; fi + done +fi + +# 3) 启动 dsh Web UI +if [ -n "${DSH_HOME:-}" ] && [ -f "$DSH_HOME/package.json" ]; then + echo "[workbench] 使用源码版 dsh: $DSH_HOME" + (cd "$DSH_HOME" && pnpm dsh web --patch "$LOCAL_YML") +else + echo "[workbench] 使用 npm 版 dsh(npx @deepseek-ai/dsh)" + npx -y @deepseek-ai/dsh web --patch "$LOCAL_YML" +fi diff --git a/workbench/cordis.template.yml b/workbench/cordis.template.yml new file mode 100644 index 0000000..f8bb4ea --- /dev/null +++ b/workbench/cordis.template.yml @@ -0,0 +1,22 @@ +# GYXX 智能工作台 —— dsh Web 组合补丁(模板)。 +# 启动脚本会把 __PLUGIN_DIR__ / __PROJECT_ROOT__ 渲染为绝对路径后生成 +# cordis.local.yml,再通过 `dsh web --patch workbench/cordis.local.yml` 加载。 +# 不要直接手工使用本文件:dsh 要求插件路径为绝对路径。 + +- insert: + - id: gyxx-workbench + name: '__PLUGIN_DIR__/plugin/gyxx-workbench.mjs' + config: + # gyxx 控制台 HTTP API(gyxx console,默认 8765 端口) + consoleBaseUrl: 'http://127.0.0.1:8765' + # 访问令牌:缺省读取进程环境变量 GYXX_CONSOLE_TOKEN + # consoleToken: '' + # 侧边栏面板桥接服务(本机回环) + bridgeHost: '127.0.0.1' + bridgePort: 8790 + # 失败监控轮询间隔(毫秒) + pollIntervalMs: 30000 + # 发现失败时是否自动创建诊断会话(默认 false,只在面板弹告警) + autoDiagnose: false + # 智能体会话的工作目录(数据根目录下的日志可用文件工具直接查看) + projectRoot: '__PROJECT_ROOT__' diff --git a/workbench/plugin/client/src/Panel.jsx b/workbench/plugin/client/src/Panel.jsx new file mode 100644 index 0000000..9f8953e --- /dev/null +++ b/workbench/plugin/client/src/Panel.jsx @@ -0,0 +1,355 @@ +/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */ + +import { useSyncExternalStore, useEffect, useCallback } from 'react' +import { getState, subscribe, update, pushToast } from './store.js' +import { api } from './api.js' + +function useWorkbench() { + return useSyncExternalStore(subscribe, getState) +} + +const STATUS_LABELS = { + success: '成功', + failed: '失败', + running: '运行中', + cancelled: '已停止', + none: '未运行', +} + +function statusOf(workflow) { + if (workflow.active_run) return 'running' + return workflow.last_run?.status ?? 'none' +} + +function formatTime(value) { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + const pad = (n) => String(n).padStart(2, '0') + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function formatDuration(seconds) { + if (seconds == null) return '' + if (seconds < 60) return `${seconds}s` + return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}` +} + +export async function refreshWorkflows() { + update({ loading: true, error: null }) + try { + const payload = await api.listWorkflows() + update({ + loading: false, + workflows: payload.workflows ?? [], + summary: payload.summary ?? null, + generatedAt: payload.generated_at ?? null, + }) + } catch (error) { + update({ loading: false, error: error.message }) + } +} + +async function selectWorkflow(workflowId) { + update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] }) + try { + const [detail, runs] = await Promise.all([ + api.workflowDetail(workflowId).catch(() => null), + api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })), + ]) + update({ detailLoading: false, detail, runs: runs.runs ?? [] }) + } catch (error) { + update({ detailLoading: false, error: error.message }) + } +} + +async function loadDiagnosis(workflowId, runId) { + update({ diagnosisLoading: true }) + try { + const payload = await api.diagnosis(workflowId, runId) + update({ diagnosisLoading: false, diagnosis: payload }) + } catch (error) { + update({ diagnosisLoading: false }) + pushToast({ kind: 'error', text: `诊断数据加载失败:${error.message}` }) + } +} + +async function doAsk(action) { + const s = getState() + if (!s.selectedId) return + const question = s.question.trim() + try { + const payload = await api.ask({ + workflow_id: s.selectedId, + action, + question, + }) + const label = { ask: '提问', diagnose: '诊断', repair: '修复' }[action] ?? '会话' + pushToast({ + kind: 'ok', + text: `已创建${label}会话(${String(payload.session_id).slice(0, 18)}…),请在左侧会话列表中查看`, + }) + update({ question: '' }) + } catch (error) { + pushToast({ kind: 'error', text: `创建会话失败:${error.message}` }) + } +} + +async function doTrigger(execute) { + const s = getState() + if (!s.selectedId) return + if (execute) { + const ok = window.confirm( + `正式执行 ${s.selectedId}(业务日期 ${s.businessDate})会对外部系统产生真实写入。确认继续?`, + ) + if (!ok) return + } + try { + await api.trigger({ + workflow_id: s.selectedId, + business_date: s.businessDate, + execute, + confirmed: execute, + }) + pushToast({ kind: 'ok', text: execute ? '已发起正式执行' : '已发起试运行(无外部副作用)' }) + setTimeout(refreshWorkflows, 1500) + } catch (error) { + pushToast({ kind: 'error', text: `启动失败:${error.message}` }) + } +} + +async function doCancel() { + const s = getState() + const active = s.detail?.workflow?.active_run + if (!s.selectedId) return + try { + if (active?.operation_id) { + await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id }) + } else { + await api.cancel({ workflow_id: s.selectedId, scheduled: true }) + } + pushToast({ kind: 'ok', text: '已发送停止指令' }) + setTimeout(refreshWorkflows, 1500) + } catch (error) { + pushToast({ kind: 'error', text: `停止失败:${error.message}` }) + } +} + +function SummaryPills({ summary, workflows }) { + const failed = workflows.filter((w) => statusOf(w) === 'failed').length + const running = workflows.filter((w) => w.active_run).length + return ( +
+ 共 {summary?.total ?? workflows.length} + {running > 0 && 运行中 {running}} + {failed > 0 && 失败 {failed}} +
+ ) +} + +function WorkflowRow({ workflow, selected, onSelect }) { + const status = statusOf(workflow) + return ( + + ) +} + +function RunList({ runs, onDiagnose }) { + if (!runs.length) return
暂无运行记录
+ return ( +
+ {runs.map((run) => ( + + ))} +
+ ) +} + +function DiagnosisView({ diagnosis, loading }) { + if (loading) return
正在加载诊断数据…
+ if (!diagnosis) return null + if (diagnosis.message) return
{diagnosis.message}
+ const run = diagnosis.run ?? {} + const steps = Array.isArray(run.steps) ? run.steps : [] + const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : [] + return ( +
+
+ 诊断:{run.run_id}({run.status}) +
+ {steps + .filter((step) => step.status === 'failed') + .map((step) => ( +
+
+ ✗ {step.id}(退出码 {step.exit_code ?? '—'}) +
+ {step.error &&
{step.error}
} +
+ ))} + {logs.map((log) => ( +
+
{log.path}
+
{log.tail}
+
+ ))} + {!steps.some((s) => s.status === 'failed') && !logs.length && ( +
该运行没有失败步骤或日志
+ )} +
+ ) +} + +export function WorkflowPanel() { + const s = useWorkbench() + + useEffect(() => { + if (!s.open) return undefined + refreshWorkflows() + const timer = setInterval(refreshWorkflows, 10_000) + return () => clearInterval(timer) + }, [s.open]) + + const onSelect = useCallback((workflowId) => { + selectWorkflow(workflowId) + }, []) + + const groups = [] + const byModule = new Map() + for (const workflow of s.workflows) { + const key = workflow.module_label ?? workflow.module + if (!byModule.has(key)) byModule.set(key, []) + byModule.get(key).push(workflow) + } + for (const [label, items] of byModule) groups.push({ label, items }) + + const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null + + return ( +
+
+ GYXX 工作流 + + + +
+ {s.error && ( +
+ 无法连接工作台桥接服务:{s.error} +
+ 请确认 dsh 已通过 workbench/cordis.yml 启动。 +
+ )} +
+
+ {groups.map((group) => ( +
+
{group.label}
+ {group.items.map((workflow) => ( + + ))} +
+ ))} + {!s.loading && !s.workflows.length && !s.error && ( +
未发现工作流
+ )} +
+ {selected && ( +
+
+ {selected.name} + + {STATUS_LABELS[statusOf(selected)]} + +
+
+ {selected.id} · 调度{' '} + {selected.schedule + ? `${selected.schedule.enabled ? '启用' : '停用'} ${ + Array.isArray(selected.schedule.at) + ? selected.schedule.at.join('/') + : (selected.schedule.at ?? '') + }` + : '无'} + {selected.next_run_at ? ` · 下次 ${formatTime(selected.next_run_at)}` : ''} +
+
+ + + + + {selected.active_run && ( + + )} +
+
+ update({ question: event.target.value })} + onKeyDown={(event) => { + if (event.key === 'Enter') doAsk('ask') + }} + /> + +
+
+ 业务日期{' '} + update({ businessDate: event.target.value })} + pattern="\d{4}-\d{2}-\d{2}" + /> +
+
最近运行
+ loadDiagnosis(selected.id, runId)} /> + +
+ )} +
+
+ ) +} diff --git a/workbench/plugin/client/src/Toasts.jsx b/workbench/plugin/client/src/Toasts.jsx new file mode 100644 index 0000000..dd06b26 --- /dev/null +++ b/workbench/plugin/client/src/Toasts.jsx @@ -0,0 +1,74 @@ +/** 失败告警浮层:轮询桥接告警,新失败弹出 toast,可一键发起诊断会话。 */ + +import { useEffect } from 'react' +import { useSyncExternalStore } from 'react' +import { getState, subscribe, update, dismissToast, pushToast } from './store.js' +import { api } from './api.js' + +function useWorkbench() { + return useSyncExternalStore(subscribe, getState) +} + +export function Toasts() { + const s = useWorkbench() + + useEffect(() => { + let stopped = false + let timer = null + const poll = async () => { + try { + const payload = await api.alerts() + const fresh = (payload.alerts ?? []).filter( + (alert) => !alert.seen && !getState().seenAlertIds[alert.id], + ) + if (fresh.length) { + const seen = { ...getState().seenAlertIds } + for (const alert of fresh) { + seen[alert.id] = true + pushToast({ kind: 'failed', text: `工作流「${alert.name}」运行失败`, alert }) + } + update({ seenAlertIds: seen }) + } + } catch { + // 桥接离线时静默 + } + if (!stopped) timer = setTimeout(poll, 30_000) + } + timer = setTimeout(poll, 8_000) + return () => { + stopped = true + if (timer) clearTimeout(timer) + } + }, []) + + if (!s.toasts.length) return null + return ( +
+ {s.toasts.map((toast) => ( +
+ {toast.text} + {toast.alert && ( + + )} + +
+ ))} +
+ ) +} diff --git a/workbench/plugin/client/src/api.js b/workbench/plugin/client/src/api.js new file mode 100644 index 0000000..d67f797 --- /dev/null +++ b/workbench/plugin/client/src/api.js @@ -0,0 +1,55 @@ +/** 工作台桥接服务客户端:所有数据经由 gyxx-workbench 宿主插件的本机回环桥接。 */ + +export function bridgeBase() { + if (typeof window !== 'undefined') { + if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__ + try { + const saved = window.localStorage?.getItem('gyxx.wb.bridge') + if (saved) return saved + } catch { + // localStorage 不可用时使用默认值 + } + } + return 'http://127.0.0.1:8790' +} + +async function request(method, path, body) { + const response = await fetch(`${bridgeBase()}${path}`, { + method, + headers: { + accept: 'application/json', + ...(body !== undefined + ? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' } + : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + let payload + try { + payload = await response.json() + } catch { + payload = { ok: false, error: `桥接服务返回了非 JSON 内容(HTTP ${response.status})` } + } + if (!response.ok) { + throw new Error(payload?.error ?? `桥接服务错误(HTTP ${response.status})`) + } + return payload +} + +export const api = { + listWorkflows: () => request('GET', '/bridge/workflows'), + workflowDetail: (workflowId) => + request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}`), + workflowRuns: (workflowId, limit = 6) => + request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`), + diagnosis: (workflowId, runId) => + request( + 'GET', + `/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ''}`, + ), + alerts: () => request('GET', '/bridge/alerts'), + markAlertSeen: (id) => request('POST', '/bridge/alerts/seen', { id }), + trigger: (payload) => request('POST', '/bridge/trigger', payload), + cancel: (payload) => request('POST', '/bridge/cancel', payload), + ask: (payload) => request('POST', '/bridge/ask', payload), +} diff --git a/workbench/plugin/client/src/index.jsx b/workbench/plugin/client/src/index.jsx new file mode 100644 index 0000000..e6b14a7 --- /dev/null +++ b/workbench/plugin/client/src/index.jsx @@ -0,0 +1,71 @@ +/** + * GYXX 工作台客户端插件入口。 + * 由 esbuild 打包为 CJS 并通过 window.__ModuleLoader__ 注册(见 scripts/build-client.mjs)。 + * 运行时只依赖 dsh 平台共享模块表中的 react。 + */ + +import { createElement, useSyncExternalStore } from 'react' +import { WorkflowPanel, refreshWorkflows } from './Panel.jsx' +import { Toasts } from './Toasts.jsx' +import { getState, subscribe, update } from './store.js' +import stylesText from './styles.css' + +export const inject = ['slots'] + +function useWorkbench() { + return useSyncExternalStore(subscribe, getState) +} + +function injectStyles() { + if (typeof document === 'undefined') return + if (document.getElementById('gyxxwb-styles')) return + const style = document.createElement('style') + style.id = 'gyxxwb-styles' + style.textContent = stylesText + document.head.appendChild(style) +} + +/** 侧边栏底部动作:工作流面板开关 + 聚合状态点。 */ +function WorkbenchFooterAction() { + const s = useWorkbench() + const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length + const running = s.workflows.filter((w) => w.active_run).length + const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success' + return createElement( + 'button', + { + type: 'button', + className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`, + title: 'GYXX 工作流工作台', + onClick: () => { + const next = !getState().open + update({ open: next }) + if (next) refreshWorkflows() + }, + }, + createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }), + createElement('span', null, '工作流'), + ) +} + +/** 覆盖层根:左侧抽屉面板 + 失败 toast。 */ +function WorkbenchOverlay() { + return createElement( + 'div', + { className: 'gyxxwb-overlay' }, + createElement(WorkflowPanel, null), + createElement(Toasts, null), + ) +} + +export function apply(ctx) { + injectStyles() + ctx.effect( + () => ctx.slots.register({ name: 'sidebar.footer.action' }, WorkbenchFooterAction), + 'gyxx-workbench: footer action', + ) + ctx.effect( + () => ctx.slots.register({ name: 'shell.overlay' }, WorkbenchOverlay), + 'gyxx-workbench: overlay', + ) +} diff --git a/workbench/plugin/client/src/store.js b/workbench/plugin/client/src/store.js new file mode 100644 index 0000000..f8d346c --- /dev/null +++ b/workbench/plugin/client/src/store.js @@ -0,0 +1,57 @@ +/** + * GYXX 工作台面板 —— 极简外部存储(避免向客户端包引入额外依赖)。 + * React 18 的 useSyncExternalStore 直接订阅。 + */ + +const listeners = new Set() + +let state = { + open: false, + loading: false, + error: null, + generatedAt: null, + summary: null, + workflows: [], + selectedId: null, + detail: null, + detailLoading: false, + runs: [], + diagnosis: null, + diagnosisLoading: false, + question: '', + businessDate: defaultBusinessDate(), + alerts: [], + seenAlertIds: {}, + toasts: [], +} + +export function defaultBusinessDate() { + const now = new Date() + now.setDate(now.getDate() - 1) + const pad = (value) => String(value).padStart(2, '0') + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +} + +export function getState() { + return state +} + +export function update(patch) { + state = { ...state, ...patch } + for (const listener of listeners) listener() +} + +export function subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function pushToast(toast) { + const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` + update({ toasts: [...state.toasts, { id, ...toast }] }) + return id +} + +export function dismissToast(id) { + update({ toasts: state.toasts.filter((toast) => toast.id !== id) }) +} diff --git a/workbench/plugin/client/src/styles.css b/workbench/plugin/client/src/styles.css new file mode 100644 index 0000000..3363ad1 --- /dev/null +++ b/workbench/plugin/client/src/styles.css @@ -0,0 +1,471 @@ +/* GYXX 工作台面板样式 —— 以文本形式内联进客户端包,运行时注入 + + +
+

GYXX 智能工作台

+ + + +
+
+
+

在左侧选择一个工作流,可提问、诊断、修复或启动。

+
+
+ + + diff --git a/workbench/plugin/gyxx-workbench.mjs b/workbench/plugin/gyxx-workbench.mjs new file mode 100644 index 0000000..3278110 --- /dev/null +++ b/workbench/plugin/gyxx-workbench.mjs @@ -0,0 +1,937 @@ +/** + * GYXX 智能工作台 — deepseek-harness 宿主插件。 + * + * 通过 gyxx-flow 控制台 HTTP API(默认 http://127.0.0.1:8765)把工作流 + * 暴露为 DeepSeek 智能体工具,并提供: + * - 7 个模型可调用的工作流工具(查询 / 诊断 / 触发 / 停止 / 定时配置) + * - 中文系统提示词(安全契约:默认试运行,正式执行必须用户确认) + * - 失败监控器(轮询 overview,发现新失败时发出告警,可选自动开诊断会话) + * - 本机回环桥接服务(默认 127.0.0.1:8790),供侧边栏面板读取数据、 + * 发起「提问 / 诊断 / 修复」会话;同时内置一个零依赖的独立面板页面 + * (GET /)作为 dsh 客户端插件加载失败时的降级 UI。 + * + * 本文件零运行时依赖(只用 Node 内置模块),经 cordis.yml --patch 以绝对 + * 路径插入 dsh Web 组合即可运行。 + */ + +import http from 'node:http' +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const name = 'gyxx-workbench' +export const inject = ['tools', 'systemPrompt'] + +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)) +const MODULE_LABELS = { + content_marketing: '内容营销', + product_commerce: '商品经营', + shop_intelligence: '店铺情报', + supply_chain: '供应链', +} + +/* ------------------------------------------------------------------ */ +/* 控制台 HTTP 客户端 */ +/* ------------------------------------------------------------------ */ + +class ConsoleApiError extends Error { + constructor(status, message) { + super(message) + this.name = 'ConsoleApiError' + this.status = status + } +} + +function makeConsoleClient(config) { + const base = new URL(config.consoleBaseUrl) + return async function consoleApi(method, path, body, options = {}) { + const headers = { accept: 'application/json' } + if (config.consoleToken) headers.authorization = `Bearer ${config.consoleToken}` + if (body !== undefined) { + headers['content-type'] = 'application/json' + headers['x-gyxx-console'] = '1' + } + if (options.ifMatch) headers['if-match'] = options.ifMatch + let response + try { + response = await fetch(new URL(path, base), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: options.signal, + }) + } catch (error) { + if (error?.name === 'AbortError') throw error + throw new ConsoleApiError( + 0, + `无法连接 gyxx 控制台 ${base.origin},请先启动 gyxx console(${error?.message ?? error})`, + ) + } + const text = await response.text() + let payload + try { + payload = JSON.parse(text) + } catch { + payload = { raw: text } + } + if (!response.ok) { + const message = + payload && typeof payload.error === 'string' + ? payload.error + : `控制台返回 HTTP ${response.status}` + throw new ConsoleApiError(response.status, message) + } + return payload + } +} + +/* ------------------------------------------------------------------ */ +/* 数据压缩:把控制台载荷裁剪为适合模型阅读的体积 */ +/* ------------------------------------------------------------------ */ + +function compactRun(run) { + if (!run) return null + const compact = { + run_id: run.run_id, + business_date: run.business_date, + mode: run.mode, + shadow: run.shadow, + status: run.status, + started_at: run.started_at, + ended_at: run.ended_at, + duration_seconds: run.duration_seconds, + error: run.error, + } + if (Array.isArray(run.steps)) { + compact.steps = run.steps.map((step) => ({ + id: step.id, + status: step.status, + exit_code: step.exit_code, + error: step.error, + })) + } + return compact +} + +function compactWorkflow(item) { + const schedule = item.schedule ?? null + return { + id: item.id, + name: item.name, + module: item.module, + module_label: MODULE_LABELS[item.module] ?? item.module, + trigger: item.trigger, + registered: item.registered, + note: item.note ?? null, + schedule: schedule + ? { + enabled: schedule.enabled, + kind: schedule.kind, + at: schedule.at, + raw: schedule, + } + : null, + next_run_at: item.next_run_at ?? null, + last_run: compactRun(item.last_run), + active_run: item.active_run + ? { + operation_id: item.active_run.operation_id, + business_date: item.active_run.business_date, + mode: item.active_run.mode, + started_at: item.active_run.started_at, + } + : null, + } +} + +function compactDetail(payload) { + const workflow = payload.workflow ?? {} + const steps = Array.isArray(workflow.steps) + ? workflow.steps.map((step) => ({ + id: step.id, + name: step.name, + description: step.description, + entry: step.entry, + timeout_seconds: step.timeout_seconds, + replay_policy: step.replay_policy, + depends_on: step.depends_on, + })) + : [] + return { + workflow: { ...compactWorkflow(workflow), steps }, + schedule_revision: payload.schedule_revision ?? null, + warnings: payload.warnings ?? [], + } +} + +/* ------------------------------------------------------------------ */ +/* 系统提示词 */ +/* ------------------------------------------------------------------ */ + +const SYSTEM_PROMPT_TEXT = `\ +你是 GYXX 智能工作台的运维智能体。gyxx-flow 是一套 LangGraph 工作流编排系统, +包含四个业务模块:内容营销(content_marketing)、商品经营(product_commerce)、 +店铺情报(shop_intelligence)、供应链(supply_chain)。每个工作流由若干步骤组成, +运行日志(RunJournal)与运行索引位于数据根目录下。 + +你可以使用以下工具(数据来自本机 gyxx 控制台 HTTP API): +- gyxx_workflow_list:列出全部工作流及其调度、最近一次运行状态。 +- gyxx_workflow_detail:查看单个工作流的定义、步骤与调度详情。 +- gyxx_workflow_runs:查看某工作流的最近运行历史(含每个步骤的退出码与错误)。 +- gyxx_workflow_diagnose:获取一次运行的完整诊断包(日志尾部已脱敏)。 +- gyxx_workflow_trigger:触发一次运行。默认 execute=false 为试运行(无副作用)。 +- gyxx_workflow_cancel:停止正在运行的手动执行或定时执行。 +- gyxx_schedule_update:启用/停用或修改定时调度(读-改-写,需要 schedule_revision)。 + +安全契约(必须遵守): +1. 默认只做只读分析与试运行。execute=true 的正式执行会对外部系统 + (PostgreSQL、飞书、电商平台)产生真实写入,只有在用户于对话中明确确认后, + 才能把 confirmed=true 一并传入;否则工具会拒绝。 +2. 修复工作流时遵循「诊断 → 定位根因 → 给出方案 → 用户确认 → 执行修复」的顺序, + 不得跳过确认直接修改调度或正式重跑。 +3. 常见修复手段:登录态失效(提示用户运行 gyxx accounts login/sync)、 + 幂等冲突(force_refresh 需真实执行且谨慎)、参数错误(改动态配置或调度)、 + 临时性失败(先试运行验证再正式重跑)。 +4. 诊断结果用中文输出,包含:失败步骤、根因推测、建议操作、风险说明。 + +当用户从工作台侧边栏选中工作流发起提问/诊断/修复时,会话的第一条消息会 +带有工作流上下文,请直接开始分析,不要反问用户基本信息。` + +/* ------------------------------------------------------------------ */ +/* 工具定义 */ +/* ------------------------------------------------------------------ */ + +const JSON_OUTPUT = { + schema: { type: 'object' }, + render: (_args, value) => [ + { type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }, + ], +} + +function toolErrorResult(error) { + return { + ok: false, + error: error instanceof ConsoleApiError ? error.message : `工具执行失败:${error?.message ?? error}`, + } +} + +function defineWorkflowTools(ctx, consoleApi) { + const register = (definition) => ctx.tools.register(definition) + + register({ + name: 'gyxx_workflow_list', + description: + '列出 GYXX 全部调度工作流:名称、模块、调度时间、最近一次运行状态与进行中的执行。', + parameters: { + type: 'object', + properties: { + module: { + type: 'string', + enum: Object.keys(MODULE_LABELS), + description: '按业务模块过滤,缺省返回全部', + }, + status: { + type: 'string', + enum: ['failed', 'running', 'success', 'none'], + description: '按最近一次运行状态过滤(none = 从未运行)', + }, + }, + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + try { + const overview = await consoleApi('GET', '/api/overview', undefined, { + signal: exec?.signal, + }) + let workflows = (overview.workflows ?? []).map(compactWorkflow) + if (args?.module) workflows = workflows.filter((w) => w.module === args.module) + if (args?.status) { + workflows = workflows.filter((w) => { + const status = w.active_run ? 'running' : (w.last_run?.status ?? 'none') + return args.status === 'running' ? status === 'running' || w.active_run : status === args.status + }) + } + return { ok: true, summary: overview.summary ?? null, workflows } + } catch (error) { + return toolErrorResult(error) + } + }, + }) + + register({ + name: 'gyxx_workflow_detail', + description: '查看单个工作流的完整定义:步骤、调度、依赖、重放策略与最近运行。', + parameters: { + type: 'object', + properties: { + workflow_id: { type: 'string', description: '工作流 ID,例如 content.metrics.daily' }, + }, + required: ['workflow_id'], + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + try { + const payload = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(args.workflow_id)}`, + undefined, + { signal: exec?.signal }, + ) + return { ok: true, ...compactDetail(payload) } + } catch (error) { + return toolErrorResult(error) + } + }, + }) + + register({ + name: 'gyxx_workflow_runs', + description: '查看某工作流最近的运行历史(含步骤级状态、退出码与脱敏错误)。', + parameters: { + type: 'object', + properties: { + workflow_id: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 20, description: '默认 8,最大 20' }, + }, + required: ['workflow_id'], + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + try { + const limit = args?.limit ?? 8 + const payload = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/runs?limit=${limit}`, + undefined, + { signal: exec?.signal }, + ) + return { + ok: true, + workflow_id: payload.workflow_id, + runs: (payload.runs ?? []).map(compactRun), + } + } catch (error) { + return toolErrorResult(error) + } + }, + }) + + register({ + name: 'gyxx_workflow_diagnose', + description: + '获取一次运行的完整诊断包:运行状态、步骤明细、journal 追踪路径与脱敏日志尾部。' + + '不传 run_id 时自动选择最近一次失败的运行(无失败则最近一次运行)。', + parameters: { + type: 'object', + properties: { + workflow_id: { type: 'string' }, + run_id: { type: 'string', description: '缺省时自动选择最近一次失败运行' }, + }, + required: ['workflow_id'], + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + try { + let runId = args?.run_id + if (!runId) { + const runs = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/runs?limit=8`, + undefined, + { signal: exec?.signal }, + ) + const list = runs.runs ?? [] + const failed = list.find((run) => run.status === 'failed') + const chosen = failed ?? list[0] + if (!chosen) { + return { ok: true, message: '该工作流还没有任何运行记录', workflow_id: args.workflow_id } + } + runId = chosen.run_id + } + const payload = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/runs/${encodeURIComponent(runId)}/diagnosis`, + undefined, + { signal: exec?.signal }, + ) + return { ok: true, ...payload, run: compactRun(payload.run) } + } catch (error) { + return toolErrorResult(error) + } + }, + }) + + register({ + name: 'gyxx_workflow_trigger', + description: + '触发工作流运行。默认 execute=false 为试运行(演练,无外部副作用);' + + '正式执行必须 execute=true 且 confirmed=true(只有在用户明确确认后才允许)。', + parameters: { + type: 'object', + properties: { + workflow_id: { type: 'string' }, + business_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: '业务日期 YYYY-MM-DD' }, + execute: { type: 'boolean', description: '缺省 false = 试运行' }, + confirmed: { type: 'boolean', description: '正式执行确认标记,须先征得用户确认' }, + shadow: { type: 'boolean', description: '影子模式(对比旧链路),缺省 false' }, + force_refresh: { type: 'boolean', description: '忽略幂等跳过强制重采(需正式执行)' }, + }, + required: ['workflow_id', 'business_date'], + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + const execute = args?.execute === true + const confirmed = args?.confirmed === true + if (execute && !confirmed) { + return { + ok: false, + error: + '正式执行(execute=true)会对外部系统产生真实写入。请先用中文向用户说明将执行的操作与影响,' + + '在用户明确确认后,再以 confirmed=true 重新调用本工具。', + } + } + try { + const payload = await consoleApi( + 'POST', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/runs`, + { + business_date: args.business_date, + execute, + shadow: args?.shadow === true, + confirmed, + force_refresh: args?.force_refresh === true, + }, + { signal: exec?.signal }, + ) + return { ok: true, launched: payload } + } catch (error) { + return toolErrorResult(error) + } + }, + }) + + register({ + name: 'gyxx_workflow_cancel', + description: '停止某工作流正在进行的执行:手动运行需要 operation_id;定时运行用 scheduled=true。', + parameters: { + type: 'object', + properties: { + workflow_id: { type: 'string' }, + operation_id: { type: 'string', description: '手动运行的操作 ID(active_run.operation_id)' }, + scheduled: { type: 'boolean', description: 'true 表示停止调度器正在执行的运行' }, + }, + required: ['workflow_id'], + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + try { + if (args?.scheduled === true) { + const payload = await consoleApi( + 'DELETE', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/scheduled-run`, + {}, + { signal: exec?.signal }, + ) + return { ok: true, result: payload } + } + if (typeof args?.operation_id !== 'string' || !args.operation_id) { + return { ok: false, error: '停止手动运行必须提供 operation_id(见 active_run.operation_id)' } + } + const payload = await consoleApi( + 'DELETE', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/runs/${encodeURIComponent(args.operation_id)}`, + {}, + { signal: exec?.signal }, + ) + return { ok: true, result: payload } + } catch (error) { + return toolErrorResult(error) + } + }, + }) + + register({ + name: 'gyxx_schedule_update', + description: + '修改工作流的定时调度(启用/停用、启动时间、周期)。采用读-改-写:' + + '先用 gyxx_workflow_detail 拿到 schedule 与 schedule_revision,再把完整调度对象' + + '与 revision 一并提交。修改前必须向用户说明并获得确认。', + parameters: { + type: 'object', + properties: { + workflow_id: { type: 'string' }, + schedule_revision: { type: 'string', description: 'gyxx_workflow_detail 返回的版本号' }, + kind: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'interval_days'] }, + at: { + description: '启动时间,HH:MM 字符串或字符串数组', + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], + }, + enabled: { type: 'boolean' }, + days: { type: 'array', items: { type: 'string' }, description: 'weekly 时的星期列表' }, + day_of_month: { type: 'integer', description: 'monthly 时的日期' }, + every_days: { type: 'integer', description: 'interval_days 时的间隔天数' }, + business_date_offset_days: { type: 'integer', description: '业务日期偏移,缺省 0' }, + }, + required: ['workflow_id', 'schedule_revision', 'kind', 'at'], + additionalProperties: false, + }, + output: JSON_OUTPUT, + async execute(args, exec) { + try { + const body = { + kind: args.kind, + at: args.at, + enabled: args?.enabled ?? true, + business_date_offset_days: args?.business_date_offset_days ?? 0, + } + for (const key of ['days', 'day_of_month', 'every_days']) { + if (args?.[key] !== undefined) body[key] = args[key] + } + const payload = await consoleApi( + 'PUT', + `/api/workflows/${encodeURIComponent(args.workflow_id)}/schedule`, + body, + { signal: exec?.signal, ifMatch: args.schedule_revision }, + ) + return { ok: true, result: payload } + } catch (error) { + return toolErrorResult(error) + } + }, + }) +} + +/* ------------------------------------------------------------------ */ +/* 提问 / 诊断 / 修复会话 */ +/* ------------------------------------------------------------------ */ + +function buildSessionPrompt(action, workflow, question) { + const label = `${workflow.id}(${workflow.name ?? workflow.id})` + const last = workflow.last_run + const context = last + ? `最近一次运行:${last.status},业务日期 ${last.business_date},run_id=${last.run_id}` + + (last.error ? `,错误:${last.error}` : '') + : '该工作流暂无运行记录' + if (action === 'diagnose') { + return ( + `【工作台诊断请求】请诊断工作流 ${label}。\n${context}\n` + + '请调用 gyxx_workflow_diagnose 获取最近一次失败运行的诊断包,' + + '按「失败步骤 → 根因推测 → 建议修复 → 风险说明」输出中文诊断报告。' + ) + } + if (action === 'repair') { + return ( + `【工作台修复请求】请修复工作流 ${label}。\n${context}\n` + + '先用 gyxx_workflow_diagnose 定位失败根因,给出候选修复方案并逐条说明影响;' + + '任何正式执行或调度修改都必须先征得我的确认(遵守安全契约),确认后再执行。' + ) + } + return ( + `【工作台提问】工作流 ${label}。\n${context}\n` + + `我的问题:${question ?? '这个工作流现在状态如何?'}\n` + + '请结合 gyxx_workflow_detail / gyxx_workflow_runs / gyxx_workflow_diagnose 回答。' + ) +} + +/* ------------------------------------------------------------------ */ +/* 桥接服务(供侧边栏面板使用) */ +/* ------------------------------------------------------------------ */ + +function isLoopbackOrigin(origin) { + if (!origin) return null + try { + const url = new URL(origin) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + if (['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) return origin + } catch { + return null + } + return null +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0 + const chunks = [] + req.on('data', (chunk) => { + size += chunk.length + if (size > 64 * 1024) { + reject(new Error('请求正文过大')) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', () => { + if (chunks.length === 0) return resolve({}) + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + } catch { + reject(new Error('请求 JSON 无效')) + } + }) + req.on('error', reject) + }) +} + +function createBridgeServer(ctx, config, consoleApi, alerts, openChatSession) { + const standalonePage = () => { + try { + return readFileSync(join(PLUGIN_DIR, 'client', 'standalone.html')) + } catch { + return Buffer.from('

gyxx-workbench bridge

', 'utf8') + } + } + + const sendJson = (req, res, status, payload) => { + const origin = isLoopbackOrigin(req.headers.origin) + const body = JSON.stringify(payload) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + ...(origin ? { 'access-control-allow-origin': origin, vary: 'origin' } : {}), + }) + res.end(body) + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + const path = url.pathname + const origin = isLoopbackOrigin(req.headers.origin) + + if (req.method === 'OPTIONS') { + res.writeHead(204, { + ...(origin + ? { + 'access-control-allow-origin': origin, + 'access-control-allow-methods': 'GET,POST,OPTIONS', + 'access-control-allow-headers': 'content-type,x-gyxx-workbench', + 'access-control-max-age': '3600', + vary: 'origin', + } + : {}), + }) + res.end() + return + } + + try { + if (req.method === 'GET' && path === '/') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(standalonePage()) + return + } + if (req.method === 'GET' && path === '/healthz') { + sendJson(req, res, 200, { ok: true, name }) + return + } + if (req.method === 'GET' && path === '/bridge/workflows') { + const overview = await consoleApi('GET', '/api/overview') + sendJson(req, res, 200, { + ok: true, + summary: overview.summary ?? null, + generated_at: overview.generated_at ?? null, + workflows: (overview.workflows ?? []).map(compactWorkflow), + }) + return + } + const detailMatch = /^\/bridge\/workflows\/([^/]+)$/.exec(path) + if (req.method === 'GET' && detailMatch) { + const payload = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(decodeURIComponent(detailMatch[1]))}`, + ) + sendJson(req, res, 200, { ok: true, ...compactDetail(payload) }) + return + } + const runsMatch = /^\/bridge\/workflows\/([^/]+)\/runs$/.exec(path) + if (req.method === 'GET' && runsMatch) { + const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 8, 1), 20) + const payload = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(decodeURIComponent(runsMatch[1]))}/runs?limit=${limit}`, + ) + sendJson(req, res, 200, { + ok: true, + workflow_id: payload.workflow_id, + runs: (payload.runs ?? []).map(compactRun), + }) + return + } + const diagnosisMatch = /^\/bridge\/workflows\/([^/]+)\/diagnosis$/.exec(path) + if (req.method === 'GET' && diagnosisMatch) { + const workflowId = decodeURIComponent(diagnosisMatch[1]) + let runId = url.searchParams.get('run_id') || '' + if (!runId) { + const runs = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(workflowId)}/runs?limit=8`, + ) + const list = runs.runs ?? [] + const chosen = list.find((run) => run.status === 'failed') ?? list[0] + if (!chosen) { + sendJson(req, res, 200, { ok: true, message: '该工作流暂无运行记录', logs: [] }) + return + } + runId = chosen.run_id + } + const payload = await consoleApi( + 'GET', + `/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(runId)}/diagnosis`, + ) + sendJson(req, res, 200, { ok: true, ...payload }) + return + } + if (req.method === 'GET' && path === '/bridge/alerts') { + sendJson(req, res, 200, { ok: true, alerts: alerts.list() }) + return + } + + // —— 以下为写操作:要求自定义头,防跨站表单提交 —— + if (req.method === 'POST' && (path === '/bridge/trigger' || path === '/bridge/cancel' || path === '/bridge/ask' || path === '/bridge/alerts/seen')) { + if (req.headers['x-gyxx-workbench'] !== '1') { + sendJson(req, res, 403, { ok: false, error: '缺少 x-gyxx-workbench 写操作标识' }) + return + } + const contentType = String(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() + if (contentType !== 'application/json') { + sendJson(req, res, 415, { ok: false, error: '写操作只接受 application/json' }) + return + } + const body = await readJsonBody(req) + + if (path === '/bridge/trigger') { + const execute = body.execute === true + const confirmed = body.confirmed === true + if (execute && !confirmed) { + sendJson(req, res, 400, { ok: false, error: '正式执行必须 confirmed=true' }) + return + } + const payload = await consoleApi( + 'POST', + `/api/workflows/${encodeURIComponent(String(body.workflow_id ?? ''))}/runs`, + { + business_date: String(body.business_date ?? ''), + execute, + shadow: body.shadow === true, + confirmed, + force_refresh: body.force_refresh === true, + }, + ) + sendJson(req, res, 200, { ok: true, launched: payload }) + return + } + if (path === '/bridge/cancel') { + const workflowId = String(body.workflow_id ?? '') + if (body.scheduled === true) { + const payload = await consoleApi( + 'DELETE', + `/api/workflows/${encodeURIComponent(workflowId)}/scheduled-run`, + {}, + ) + sendJson(req, res, 200, { ok: true, result: payload }) + return + } + const operationId = String(body.operation_id ?? '') + if (!operationId) { + sendJson(req, res, 400, { ok: false, error: '缺少 operation_id' }) + return + } + const payload = await consoleApi( + 'DELETE', + `/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(operationId)}`, + {}, + ) + sendJson(req, res, 200, { ok: true, result: payload }) + return + } + if (path === '/bridge/alerts/seen') { + alerts.markSeen(typeof body.id === 'string' ? body.id : null) + sendJson(req, res, 200, { ok: true }) + return + } + // /bridge/ask —— 创建工作流上下文会话 + const workflowId = String(body.workflow_id ?? '') + const action = ['ask', 'diagnose', 'repair'].includes(body.action) ? body.action : 'ask' + let workflow + try { + const detail = await consoleApi('GET', `/api/workflows/${encodeURIComponent(workflowId)}`) + workflow = compactWorkflow(detail.workflow ?? {}) + } catch { + workflow = { id: workflowId, name: workflowId, last_run: null } + } + const prompt = buildSessionPrompt(action, workflow, typeof body.question === 'string' ? body.question : '') + const sessionId = await openChatSession(prompt) + if (!sessionId) { + sendJson(req, res, 503, { + ok: false, + error: '当前 dsh 组合不支持编程式会话创建,请在对话中直接提问', + }) + return + } + sendJson(req, res, 200, { ok: true, session_id: sessionId, action }) + return + } + + sendJson(req, res, 404, { ok: false, error: '接口不存在' }) + } catch (error) { + const status = error instanceof ConsoleApiError && error.status ? error.status : 500 + sendJson(req, res, status, { + ok: false, + error: error?.message ?? '桥接服务内部错误', + }) + } + }) + + return server +} + +/* ------------------------------------------------------------------ */ +/* 插件入口 */ +/* ------------------------------------------------------------------ */ + +export function apply(ctx, rawConfig) { + const config = { + consoleBaseUrl: process.env.GYXX_CONSOLE_URL ?? 'http://127.0.0.1:8765', + consoleToken: process.env.GYXX_CONSOLE_TOKEN ?? '', + bridgeHost: '127.0.0.1', + bridgePort: 8790, + pollIntervalMs: 30_000, + autoDiagnose: false, + projectRoot: process.cwd(), + ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}), + } + const consoleApi = makeConsoleClient(config) + + // 1) 智能体工具 + defineWorkflowTools(ctx, consoleApi) + + // 2) 系统提示词 + ctx.systemPrompt.section({ + name: 'gyxx-workbench', + order: 700, + text: SYSTEM_PROMPT_TEXT, + }) + + // 3) 失败监控器 + 告警缓存 + const state = { + seen: new Map(), + items: [], + } + const alerts = { + list: () => state.items.map((item) => ({ ...item })), + markSeen(id) { + if (id === null) state.items.forEach((item) => (item.seen = true)) + else { + const target = state.items.find((item) => item.id === id) + if (target) target.seen = true + } + }, + push(alert) { + state.items.unshift(alert) + if (state.items.length > 50) state.items.length = 50 + }, + } + + const openChatSession = async (prompt) => { + const agents = typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents + if (!agents || typeof agents.create !== 'function') return null + const sessionId = `gyxx-wb-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}` + const handle = await agents.create({ + sessionId, + meta: { cwd: config.projectRoot, origin: 'gyxx-workbench', isSeeded: true }, + }) + handle.agent.followup({ + id: randomUUID(), + role: 'user', + content: [{ type: 'text', text: prompt }], + source: { kind: 'user' }, + }) + return sessionId + } + + ctx.effect(() => { + let stopped = false + let timer = null + const poll = async () => { + try { + const overview = await consoleApi('GET', '/api/overview') + for (const item of overview.workflows ?? []) { + const last = item.last_run + if (!last || last.status !== 'failed') { + if (last && last.status !== 'failed') state.seen.delete(item.id) + continue + } + if (state.seen.get(item.id) === last.run_id) continue + state.seen.set(item.id, last.run_id) + const alert = { + id: randomUUID(), + kind: 'workflow-failed', + workflow_id: item.id, + name: item.name ?? item.id, + run_id: last.run_id, + business_date: last.business_date ?? null, + error: last.error ?? null, + at: new Date().toISOString(), + seen: false, + } + alerts.push(alert) + try { + ctx.emit('gyxx-workbench/alert', alert) + } catch { + // 自定义事件在某些组合上不可用时忽略 + } + if (config.autoDiagnose) { + const prompt = buildSessionPrompt('diagnose', compactWorkflow(item), '') + openChatSession(prompt)?.catch(() => {}) + } + } + } catch { + // 控制台离线时静默,下一轮继续 + } + if (!stopped) timer = setTimeout(poll, config.pollIntervalMs) + } + timer = setTimeout(poll, 5_000) + return () => { + stopped = true + if (timer) clearTimeout(timer) + } + }, 'gyxx-workbench: monitor') + + // 4) 桥接服务(侧边栏面板数据 + 会话创建 + 降级页面) + ctx.effect(async () => { + const server = createBridgeServer(ctx, config, consoleApi, alerts, openChatSession) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(config.bridgePort, config.bridgeHost, resolve) + }) + } catch (error) { + // 端口占用等问题不应阻断插件加载(工具与监控仍可用) + ctx.logger?.warn?.( + `[gyxx-workbench] 桥接服务启动失败(${error?.message ?? error}),侧边栏面板不可用`, + ) + try { + server.close() + } catch { + // 忽略关闭异常 + } + return () => {} + } + const address = server.address() + ctx.logger?.info?.( + `[gyxx-workbench] 桥接服务已启动: http://${config.bridgeHost}:${address?.port ?? config.bridgePort}/`, + ) + return () => + new Promise((resolve) => { + server.close(() => resolve()) + }) + }, 'gyxx-workbench: bridge') +} diff --git a/workbench/plugin/lib/client.js b/workbench/plugin/lib/client.js new file mode 100644 index 0000000..4e4278b --- /dev/null +++ b/workbench/plugin/lib/client.js @@ -0,0 +1,566 @@ +window.__ModuleLoader__.load({ id: "@gyxx/dsh-plugin-gyxx-workbench", factory: (require) => { +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// client/src/index.jsx +var index_exports = {}; +__export(index_exports, { + apply: () => apply, + inject: () => inject +}); +module.exports = __toCommonJS(index_exports); +var import_react4 = require("react"); + +// client/src/Panel.jsx +var import_react = require("react"); + +// client/src/store.js +var listeners = /* @__PURE__ */ new Set(); +var state = { + open: false, + loading: false, + error: null, + generatedAt: null, + summary: null, + workflows: [], + selectedId: null, + detail: null, + detailLoading: false, + runs: [], + diagnosis: null, + diagnosisLoading: false, + question: "", + businessDate: defaultBusinessDate(), + alerts: [], + seenAlertIds: {}, + toasts: [] +}; +function defaultBusinessDate() { + const now = /* @__PURE__ */ new Date(); + now.setDate(now.getDate() - 1); + const pad = (value) => String(value).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} +function getState() { + return state; +} +function update(patch) { + state = { ...state, ...patch }; + for (const listener of listeners) listener(); +} +function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); +} +function pushToast(toast) { + const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + update({ toasts: [...state.toasts, { id, ...toast }] }); + return id; +} +function dismissToast(id) { + update({ toasts: state.toasts.filter((toast) => toast.id !== id) }); +} + +// client/src/api.js +function bridgeBase() { + if (typeof window !== "undefined") { + if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__; + try { + const saved = window.localStorage?.getItem("gyxx.wb.bridge"); + if (saved) return saved; + } catch { + } + } + return "http://127.0.0.1:8790"; +} +async function request(method, path, body) { + const response = await fetch(`${bridgeBase()}${path}`, { + method, + headers: { + accept: "application/json", + ...body !== void 0 ? { "content-type": "application/json", "x-gyxx-workbench": "1" } : {} + }, + body: body === void 0 ? void 0 : JSON.stringify(body) + }); + let payload; + try { + payload = await response.json(); + } catch { + payload = { ok: false, error: `\u6865\u63A5\u670D\u52A1\u8FD4\u56DE\u4E86\u975E JSON \u5185\u5BB9\uFF08HTTP ${response.status}\uFF09` }; + } + if (!response.ok) { + throw new Error(payload?.error ?? `\u6865\u63A5\u670D\u52A1\u9519\u8BEF\uFF08HTTP ${response.status}\uFF09`); + } + return payload; +} +var api = { + listWorkflows: () => request("GET", "/bridge/workflows"), + workflowDetail: (workflowId) => request("GET", `/bridge/workflows/${encodeURIComponent(workflowId)}`), + workflowRuns: (workflowId, limit = 6) => request("GET", `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`), + diagnosis: (workflowId, runId) => request( + "GET", + `/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ""}` + ), + alerts: () => request("GET", "/bridge/alerts"), + markAlertSeen: (id) => request("POST", "/bridge/alerts/seen", { id }), + trigger: (payload) => request("POST", "/bridge/trigger", payload), + cancel: (payload) => request("POST", "/bridge/cancel", payload), + ask: (payload) => request("POST", "/bridge/ask", payload) +}; + +// client/src/Panel.jsx +var import_jsx_runtime = require("react/jsx-runtime"); +function useWorkbench() { + return (0, import_react.useSyncExternalStore)(subscribe, getState); +} +var STATUS_LABELS = { + success: "\u6210\u529F", + failed: "\u5931\u8D25", + running: "\u8FD0\u884C\u4E2D", + cancelled: "\u5DF2\u505C\u6B62", + none: "\u672A\u8FD0\u884C" +}; +function statusOf(workflow) { + if (workflow.active_run) return "running"; + return workflow.last_run?.status ?? "none"; +} +function formatTime(value) { + if (!value) return "\u2014"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + const pad = (n) => String(n).padStart(2, "0"); + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} +function formatDuration(seconds) { + if (seconds == null) return ""; + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ""}`; +} +async function refreshWorkflows() { + update({ loading: true, error: null }); + try { + const payload = await api.listWorkflows(); + update({ + loading: false, + workflows: payload.workflows ?? [], + summary: payload.summary ?? null, + generatedAt: payload.generated_at ?? null + }); + } catch (error) { + update({ loading: false, error: error.message }); + } +} +async function selectWorkflow(workflowId) { + update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] }); + try { + const [detail, runs] = await Promise.all([ + api.workflowDetail(workflowId).catch(() => null), + api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })) + ]); + update({ detailLoading: false, detail, runs: runs.runs ?? [] }); + } catch (error) { + update({ detailLoading: false, error: error.message }); + } +} +async function loadDiagnosis(workflowId, runId) { + update({ diagnosisLoading: true }); + try { + const payload = await api.diagnosis(workflowId, runId); + update({ diagnosisLoading: false, diagnosis: payload }); + } catch (error) { + update({ diagnosisLoading: false }); + pushToast({ kind: "error", text: `\u8BCA\u65AD\u6570\u636E\u52A0\u8F7D\u5931\u8D25\uFF1A${error.message}` }); + } +} +async function doAsk(action) { + const s = getState(); + if (!s.selectedId) return; + const question = s.question.trim(); + try { + const payload = await api.ask({ + workflow_id: s.selectedId, + action, + question + }); + const label = { ask: "\u63D0\u95EE", diagnose: "\u8BCA\u65AD", repair: "\u4FEE\u590D" }[action] ?? "\u4F1A\u8BDD"; + pushToast({ + kind: "ok", + text: `\u5DF2\u521B\u5EFA${label}\u4F1A\u8BDD\uFF08${String(payload.session_id).slice(0, 18)}\u2026\uFF09\uFF0C\u8BF7\u5728\u5DE6\u4FA7\u4F1A\u8BDD\u5217\u8868\u4E2D\u67E5\u770B` + }); + update({ question: "" }); + } catch (error) { + pushToast({ kind: "error", text: `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A${error.message}` }); + } +} +async function doTrigger(execute) { + const s = getState(); + if (!s.selectedId) return; + if (execute) { + const ok = window.confirm( + `\u6B63\u5F0F\u6267\u884C ${s.selectedId}\uFF08\u4E1A\u52A1\u65E5\u671F ${s.businessDate}\uFF09\u4F1A\u5BF9\u5916\u90E8\u7CFB\u7EDF\u4EA7\u751F\u771F\u5B9E\u5199\u5165\u3002\u786E\u8BA4\u7EE7\u7EED\uFF1F` + ); + if (!ok) return; + } + try { + await api.trigger({ + workflow_id: s.selectedId, + business_date: s.businessDate, + execute, + confirmed: execute + }); + pushToast({ kind: "ok", text: execute ? "\u5DF2\u53D1\u8D77\u6B63\u5F0F\u6267\u884C" : "\u5DF2\u53D1\u8D77\u8BD5\u8FD0\u884C\uFF08\u65E0\u5916\u90E8\u526F\u4F5C\u7528\uFF09" }); + setTimeout(refreshWorkflows, 1500); + } catch (error) { + pushToast({ kind: "error", text: `\u542F\u52A8\u5931\u8D25\uFF1A${error.message}` }); + } +} +async function doCancel() { + const s = getState(); + const active = s.detail?.workflow?.active_run; + if (!s.selectedId) return; + try { + if (active?.operation_id) { + await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id }); + } else { + await api.cancel({ workflow_id: s.selectedId, scheduled: true }); + } + pushToast({ kind: "ok", text: "\u5DF2\u53D1\u9001\u505C\u6B62\u6307\u4EE4" }); + setTimeout(refreshWorkflows, 1500); + } catch (error) { + pushToast({ kind: "error", text: `\u505C\u6B62\u5931\u8D25\uFF1A${error.message}` }); + } +} +function SummaryPills({ summary, workflows }) { + const failed = workflows.filter((w) => statusOf(w) === "failed").length; + const running = workflows.filter((w) => w.active_run).length; + return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-pills", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gyxxwb-pill", children: [ + "\u5171 ", + summary?.total ?? workflows.length + ] }), + running > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gyxxwb-pill gyxxwb-pill-running", children: [ + "\u8FD0\u884C\u4E2D ", + running + ] }), + failed > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gyxxwb-pill gyxxwb-pill-failed", children: [ + "\u5931\u8D25 ", + failed + ] }) + ] }); +} +function WorkflowRow({ workflow, selected, onSelect }) { + const status = statusOf(workflow); + return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + "button", + { + type: "button", + className: `gyxxwb-row ${selected ? "gyxxwb-row-selected" : ""}`, + onClick: () => onSelect(workflow.id), + children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `gyxxwb-dot gyxxwb-dot-${status}` }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gyxxwb-row-main", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gyxxwb-row-name", children: workflow.name }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gyxxwb-row-id", children: workflow.id }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gyxxwb-row-meta", children: workflow.last_run ? formatTime(workflow.last_run.ended_at ?? workflow.last_run.started_at) : "\u2014" }) + ] + } + ); +} +function RunList({ runs, onDiagnose }) { + if (!runs.length) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-empty", children: "\u6682\u65E0\u8FD0\u884C\u8BB0\u5F55" }); + return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-runs", children: runs.map((run) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + "button", + { + type: "button", + className: "gyxxwb-run", + onClick: () => onDiagnose(run.run_id), + title: "\u70B9\u51FB\u67E5\u770B\u8BCA\u65AD\u8BE6\u60C5", + children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `gyxxwb-dot gyxxwb-dot-${run.status}` }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: run.business_date }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gyxxwb-run-mode", children: run.mode === "execute" ? "\u6B63\u5F0F" : "\u8BD5\u8FD0\u884C" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: formatDuration(run.duration_seconds) }), + run.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gyxxwb-run-error", children: run.error.slice(0, 60) }) + ] + }, + run.run_id + )) }); +} +function DiagnosisView({ diagnosis, loading }) { + if (loading) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-empty", children: "\u6B63\u5728\u52A0\u8F7D\u8BCA\u65AD\u6570\u636E\u2026" }); + if (!diagnosis) return null; + if (diagnosis.message) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-empty", children: diagnosis.message }); + const run = diagnosis.run ?? {}; + const steps = Array.isArray(run.steps) ? run.steps : []; + const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []; + return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-diagnosis", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-diagnosis-head", children: [ + "\u8BCA\u65AD\uFF1A", + run.run_id, + "\uFF08", + run.status, + "\uFF09" + ] }), + steps.filter((step) => step.status === "failed").map((step) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-step-fail", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-step-id", children: [ + "\u2717 ", + step.id, + "\uFF08\u9000\u51FA\u7801 ", + step.exit_code ?? "\u2014", + "\uFF09" + ] }), + step.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "gyxxwb-pre", children: step.error }) + ] }, step.id)), + logs.map((log) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-log", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-log-path", children: log.path }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "gyxxwb-pre", children: log.tail }) + ] }, log.path)), + !steps.some((s) => s.status === "failed") && !logs.length && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-empty", children: "\u8BE5\u8FD0\u884C\u6CA1\u6709\u5931\u8D25\u6B65\u9AA4\u6216\u65E5\u5FD7" }) + ] }); +} +function WorkflowPanel() { + const s = useWorkbench(); + (0, import_react.useEffect)(() => { + if (!s.open) return void 0; + refreshWorkflows(); + const timer = setInterval(refreshWorkflows, 1e4); + return () => clearInterval(timer); + }, [s.open]); + const onSelect = (0, import_react.useCallback)((workflowId) => { + selectWorkflow(workflowId); + }, []); + const groups = []; + const byModule = /* @__PURE__ */ new Map(); + for (const workflow of s.workflows) { + const key = workflow.module_label ?? workflow.module; + if (!byModule.has(key)) byModule.set(key, []); + byModule.get(key).push(workflow); + } + for (const [label, items] of byModule) groups.push({ label, items }); + const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null; + return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: `gyxxwb-panel ${s.open ? "gyxxwb-panel-open" : ""}`, children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-panel-head", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gyxxwb-title", children: "GYXX \u5DE5\u4F5C\u6D41" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SummaryPills, { summary: s.summary, workflows: s.workflows }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "gyxxwb-icon-btn", title: "\u5237\u65B0", onClick: refreshWorkflows, children: "\u27F3" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + className: "gyxxwb-icon-btn", + title: "\u5173\u95ED", + onClick: () => update({ open: false }), + children: "\u2715" + } + ) + ] }), + s.error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-error", children: [ + "\u65E0\u6CD5\u8FDE\u63A5\u5DE5\u4F5C\u53F0\u6865\u63A5\u670D\u52A1\uFF1A", + s.error, + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("br", {}), + "\u8BF7\u786E\u8BA4 dsh \u5DF2\u901A\u8FC7 workbench/cordis.yml \u542F\u52A8\u3002" + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-panel-body", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-list", children: [ + groups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-group", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-group-label", children: group.label }), + group.items.map((workflow) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + WorkflowRow, + { + workflow, + selected: workflow.id === s.selectedId, + onSelect + }, + workflow.id + )) + ] }, group.label)), + !s.loading && !s.workflows.length && !s.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-empty", children: "\u672A\u53D1\u73B0\u5DE5\u4F5C\u6D41" }) + ] }), + selected && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-detail", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-detail-title", children: [ + selected.name, + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `gyxxwb-status gyxxwb-status-${statusOf(selected)}`, children: STATUS_LABELS[statusOf(selected)] }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-detail-sub", children: [ + selected.id, + " \xB7 \u8C03\u5EA6", + " ", + selected.schedule ? `${selected.schedule.enabled ? "\u542F\u7528" : "\u505C\u7528"} ${Array.isArray(selected.schedule.at) ? selected.schedule.at.join("/") : selected.schedule.at ?? ""}` : "\u65E0", + selected.next_run_at ? ` \xB7 \u4E0B\u6B21 ${formatTime(selected.next_run_at)}` : "" + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-actions", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: () => doAsk("diagnose"), children: "\u8BCA\u65AD" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: () => doAsk("repair"), children: "\u4FEE\u590D" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: () => doTrigger(false), children: "\u8BD5\u8FD0\u884C" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "gyxxwb-danger", onClick: () => doTrigger(true), children: "\u6B63\u5F0F\u8FD0\u884C" }), + selected.active_run && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "gyxxwb-danger", onClick: doCancel, children: "\u505C\u6B62" }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-ask", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "input", + { + value: s.question, + placeholder: "\u5C31\u8FD9\u4E2A\u5DE5\u4F5C\u6D41\u63D0\u95EE\uFF0C\u4F8B\u5982\uFF1A\u6628\u5929\u4E3A\u4EC0\u4E48\u5931\u8D25\uFF1F", + onChange: (event) => update({ question: event.target.value }), + onKeyDown: (event) => { + if (event.key === "Enter") doAsk("ask"); + } + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: () => doAsk("ask"), children: "\u63D0\u95EE" }) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gyxxwb-date-row", children: [ + "\u4E1A\u52A1\u65E5\u671F", + " ", + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "input", + { + value: s.businessDate, + onChange: (event) => update({ businessDate: event.target.value }), + pattern: "\\d{4}-\\d{2}-\\d{2}" + } + ) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gyxxwb-section-label", children: "\u6700\u8FD1\u8FD0\u884C" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RunList, { runs: s.runs, onDiagnose: (runId) => loadDiagnosis(selected.id, runId) }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DiagnosisView, { diagnosis: s.diagnosis, loading: s.diagnosisLoading }) + ] }) + ] }) + ] }); +} + +// client/src/Toasts.jsx +var import_react2 = require("react"); +var import_react3 = require("react"); +var import_jsx_runtime2 = require("react/jsx-runtime"); +function useWorkbench2() { + return (0, import_react3.useSyncExternalStore)(subscribe, getState); +} +function Toasts() { + const s = useWorkbench2(); + (0, import_react2.useEffect)(() => { + let stopped = false; + let timer = null; + const poll = async () => { + try { + const payload = await api.alerts(); + const fresh = (payload.alerts ?? []).filter( + (alert) => !alert.seen && !getState().seenAlertIds[alert.id] + ); + if (fresh.length) { + const seen = { ...getState().seenAlertIds }; + for (const alert of fresh) { + seen[alert.id] = true; + pushToast({ kind: "failed", text: `\u5DE5\u4F5C\u6D41\u300C${alert.name}\u300D\u8FD0\u884C\u5931\u8D25`, alert }); + } + update({ seenAlertIds: seen }); + } + } catch { + } + if (!stopped) timer = setTimeout(poll, 3e4); + }; + timer = setTimeout(poll, 8e3); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; + }, []); + if (!s.toasts.length) return null; + return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gyxxwb-toasts", children: s.toasts.map((toast) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: `gyxxwb-toast gyxxwb-toast-${toast.kind}`, children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gyxxwb-toast-text", children: toast.text }), + toast.alert && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( + "button", + { + type: "button", + onClick: async () => { + try { + await api.ask({ workflow_id: toast.alert.workflow_id, action: "diagnose" }); + await api.markAlertSeen(toast.alert.id).catch(() => { + }); + pushToast({ kind: "ok", text: "\u5DF2\u521B\u5EFA\u8BCA\u65AD\u4F1A\u8BDD\uFF0C\u8BF7\u5728\u5DE6\u4FA7\u4F1A\u8BDD\u5217\u8868\u67E5\u770B" }); + } catch (error) { + pushToast({ kind: "error", text: `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A${error.message}` }); + } + dismissToast(toast.id); + }, + children: "\u7ACB\u5373\u8BCA\u65AD" + } + ), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", title: "\u5173\u95ED", onClick: () => dismissToast(toast.id), children: "\u2715" }) + ] }, toast.id)) }); +} + +// client/src/styles.css +var styles_default = "/* GYXX \u5DE5\u4F5C\u53F0\u9762\u677F\u6837\u5F0F \u2014\u2014 \u4EE5\u6587\u672C\u5F62\u5F0F\u5185\u8054\u8FDB\u5BA2\u6237\u7AEF\u5305\uFF0C\u8FD0\u884C\u65F6\u6CE8\u5165