Compare commits

..
2 Commits
Author SHA1 Message Date
wangyunlong b1861f69cd chore(workbench): 移除 dsh 工作台集成
工作台以 deepseek-harness 为 agent 底座,把现有工作流以 dsh 插件接入 Web UI。
该路线已停用,移除整个 workbench/ 与配套文档,避免留下点了启动不起来的死入口。

- 删除 workbench/:宿主插件 gyxx-workbench.mjs、侧边栏面板前端、cordis 配置
  模板、启动脚本、独立面板与 node 冒烟测试
- 删除 docs/workbench.md 与 README 的「智能工作台」章节
- .gitignore 去掉 workbench 本地产物两条规则
- 基线结构测试的 docs 白名单同步移除 workbench.md

工作流能力不受影响:gyxx console、调度器、运行记录与诊断接口均未改动。
2026-10-19 11:19:31 +08:00
wangyunlong 2ef635be0e feat(ops): 执行日志与 cookie 失效遥测入库
新增 PostgreSQL 运维审计层,本地 run.json 仍是权威源,数据库做跨机器可查、
可长期留存的关系型镜像。

数据面:
- ops_workflow_runs / ops_workflow_run_steps:每次运行与每次步骤尝试一行,
  保留状态、模式、耗时、exit_code、错误与 journal 路径
- ops_account_cookie_events:登录态观测事件流,只存 cookie 指纹,不落明文值
- ops_account_cookie_lifetimes / ops_account_cookie_stats:一份 cookie 的实际
  寿命与账号级平均/中位/最短/最长,用于发现失效规律;同时记录 cookie 自带
  的声明过期时间,可与实际寿命对比区分自然到期与服务端提前吊销

采集点:
- RunIndex.index_journal 镜像运行记录,覆盖 CLI、控制台与调度器全部路径
- CookieGate 的 gate 判定与 AccountKeepaliveManager 的周期探活(默认 10 分钟)
  共同提供 cookie 观测,探活决定失效时间的精度

可用性:
- 新增 gyxx audit runs|cookies|cookie-stats|schema,支持 --json
- 首次写入自动幂等建表,建表用 advisory lock 串行化,避免并发 DDL 死锁
- 审计写入永不抛出异常:连续失败熔断冷却并落 logs/ops-audit 日志,
  数据库不可用不影响工作流执行
2026-10-19 11:01:14 +08:00
31 changed files with 1894 additions and 3993 deletions

No files matched your search

-4
View File
@@ -42,7 +42,3 @@ Thumbs.db
/output/ /output/
/nul /nul
/new_all_bag.xlsx /new_all_bag.xlsx
# Workbench (dsh plugin) local artifacts
/workbench/plugin/node_modules/
/workbench/cordis.local.yml
-11
View File
@@ -40,17 +40,6 @@ var/ 默认运行数据;不属于源码
- `shop_intelligence`:店铺、竞店和京东自营业绩。 - `shop_intelligence`:店铺、竞店和京东自营业绩。
- `supply_chain`:采购确认、补货、库存预警和采购单更新。 - `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 ```powershell
-89
View File
@@ -1,89 +0,0 @@
# 智能工作台设计(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` → 面板选中工作流 → 提问/诊断/修复/试运行。
+57
View File
@@ -223,6 +223,8 @@ class AccountKeepaliveManager:
self._next_due: dict[str, float] = {} self._next_due: dict[str, float] = {}
self._locks = LockManager(settings.data_root / "state" / "locks") self._locks = LockManager(settings.data_root / "state" / "locks")
self._login_runner = login_runner or self._run_configured_login self._login_runner = login_runner or self._run_configured_login
self._audit_store: Any | None = None
self._audit_seen: dict[str, tuple[str, float]] = {}
def tick(self) -> list[dict[str, object]]: def tick(self) -> list[dict[str, object]]:
"""Run due account refreshes once; never starts work in dry-run mode.""" """Run due account refreshes once; never starts work in dry-run mode."""
@@ -586,8 +588,63 @@ class AccountKeepaliveManager:
if result.get("status") == "success": if result.get("status") == "success":
payload["last_success_at"] = timestamp payload["last_success_at"] = timestamp
atomic_write_json(path, payload) atomic_write_json(path, payload)
self._audit_keepalive(account, result)
return dict(result) return dict(result)
_AUDIT_STATE_BY_PROBE = {
"success": "healthy",
"expired": "expired",
"not_logged_in": "not_logged_in",
"busy": "busy",
"disabled": "disabled",
}
def _audit_keepalive(self, account: Any, result: dict[str, object]) -> None:
"""Record probe outcomes so the real cookie lifetime becomes measurable.
The resident scheduler probes every ``keepalive_interval_seconds``
(10 minutes by default), which is what turns "logins seem to last about
a week" into a measured distribution. Repeated identical outcomes
within an hour collapse into one row, so the event table tracks state
changes rather than polling noise.
"""
try:
from gyxx_flow.ops.audit import snapshot_for_account
probe = str(result.get("status", "") or "error")
state = self._AUDIT_STATE_BY_PROBE.get(probe, "probe_error")
key = str(account.account_id)
previous = self._audit_seen.get(key)
if previous is not None:
previous_state, previous_at = previous
if previous_state == state and (self._clock() - previous_at) < 3600:
return
store = self._audit()
if store is None or not store.enabled:
return
if store.record_cookie_event(
account_id=key,
state=state,
snapshot=snapshot_for_account(account),
probe_status=probe,
probe_source="keepalive",
detail=str(result.get("error_type", "") or ""),
):
self._audit_seen[key] = (state, self._clock())
except Exception: # noqa: BLE001 - telemetry must never break keepalive
return
def _audit(self) -> Any | None:
if self._audit_store is None:
try:
from gyxx_flow.ops.audit import AuditStore
self._audit_store = AuditStore(self.settings.data_root)
except Exception: # noqa: BLE001
return None
return self._audit_store
def _vault_cookie_valid(account: Any, store: BrowserCookieStore) -> bool: def _vault_cookie_valid(account: Any, store: BrowserCookieStore) -> bool:
if not account.cookie_file.exists(): if not account.cookie_file.exists():
+178
View File
@@ -229,6 +229,36 @@ def build_parser() -> argparse.ArgumentParser:
status_parser = acceptance_commands.add_parser("status") status_parser = acceptance_commands.add_parser("status")
status_parser.add_argument("--json", action="store_true") status_parser.add_argument("--json", action="store_true")
status_parser.add_argument("--output", type=Path) status_parser.add_argument("--output", type=Path)
audit_parser = commands.add_parser(
"audit", help="query the PostgreSQL operational audit tables"
)
audit_commands = audit_parser.add_subparsers(dest="audit_command", required=True)
audit_runs = audit_commands.add_parser("runs", help="recent workflow runs")
audit_runs.add_argument("--workflow")
audit_runs.add_argument("--status")
audit_runs.add_argument("--date", dest="business_date")
audit_runs.add_argument("--limit", type=int, default=30)
audit_runs.add_argument("--json", action="store_true")
audit_cookies = audit_commands.add_parser(
"cookies", help="recent cookie observations"
)
audit_cookies.add_argument("--account")
audit_cookies.add_argument("--state")
audit_cookies.add_argument("--limit", type=int, default=30)
audit_cookies.add_argument("--json", action="store_true")
audit_stats = audit_commands.add_parser(
"cookie-stats", help="measured cookie lifetime per account"
)
audit_stats.add_argument("--account")
audit_stats.add_argument("--json", action="store_true")
audit_commands.add_parser(
"schema", help="print the DDL that creates the audit tables"
)
return parser return parser
@@ -304,6 +334,8 @@ def main(
and arguments.acceptance_command == "status" and arguments.acceptance_command == "status"
): ):
return _acceptance_status_command(arguments, resolved_settings, output) return _acceptance_status_command(arguments, resolved_settings, output)
if arguments.command == "audit":
return _audit_command(arguments, resolved_settings, output)
if arguments.command == "scripts" and arguments.scripts_command == "list": if arguments.command == "scripts" and arguments.scripts_command == "list":
return _list_scripts(arguments, output) return _list_scripts(arguments, output)
if arguments.command == "scripts" and arguments.scripts_command == "run": if arguments.command == "scripts" and arguments.scripts_command == "run":
@@ -549,6 +581,152 @@ def _acceptance_status_command(
return EXIT_SUCCESS if report.is_complete else EXIT_ACCEPTANCE_INCOMPLETE return EXIT_SUCCESS if report.is_complete else EXIT_ACCEPTANCE_INCOMPLETE
def _format_seconds(value: object) -> str:
"""Human-readable duration for the audit tables (None -> '-')."""
if value is None:
return "-"
try:
seconds = int(value)
except (TypeError, ValueError):
return "-"
if seconds >= 86400:
return f"{seconds / 86400:.1f}d"
if seconds >= 3600:
return f"{seconds / 3600:.1f}h"
if seconds >= 60:
return f"{seconds / 60:.0f}m"
return f"{seconds}s"
def _format_timestamp(value: object) -> str:
if value is None:
return "-"
text = value.isoformat() if hasattr(value, "isoformat") else str(value)
return text.replace("T", " ")[:19]
def _audit_command(
arguments: argparse.Namespace, settings: Settings, output: TextIO
) -> int:
from gyxx_flow.ops.audit import SCHEMA_SQL, AuditStore
command = arguments.audit_command
if command == "schema":
output.write(SCHEMA_SQL.strip() + "\n")
return EXIT_SUCCESS
store = AuditStore(settings.data_root)
if not store.enabled:
output.write("审计已关闭(GYXX_OPS_AUDIT=off)\n")
return EXIT_CONFIGURATION
if command == "runs":
rows = store.query_runs(
workflow_id=arguments.workflow,
status=arguments.status,
business_date=arguments.business_date,
limit=arguments.limit,
)
elif command == "cookies":
rows = store.query_cookie_events(
account_id=arguments.account,
state=arguments.state,
limit=arguments.limit,
)
elif command == "cookie-stats":
rows = (
store.query_cookie_lifetimes(account_id=arguments.account)
if arguments.account
else store.query_cookie_stats()
)
else: # pragma: no cover - argparse 已限制取值
raise CliConfigurationError(f"unsupported audit command: {command}")
if store.last_error and not rows:
output.write(f"审计库不可用:{store.last_error}\n")
return EXIT_CONFIGURATION
if getattr(arguments, "json", False):
_write_json(output, {"rows": rows})
return EXIT_SUCCESS
if not rows:
output.write("暂无审计记录\n")
return EXIT_SUCCESS
if command == "runs":
output.write(
f"{'started':19s} {'workflow':40s} {'date':10s} {'mode':7s} "
f"{'status':8s} {'dur':>6s} steps\n"
)
for row in rows:
steps = (
f"{row.get('step_success', 0)}/{row.get('step_total', 0)}"
f" failed={row.get('step_failed', 0)}"
)
output.write(
f"{_format_timestamp(row.get('started_at')):19s} "
f"{str(row.get('workflow_id', ''))[:40]:40s} "
f"{_format_timestamp(row.get('business_date'))[:10]:10s} "
f"{str(row.get('mode', '')):7s} "
f"{str(row.get('status', '')):8s} "
f"{_format_seconds(row.get('duration_seconds')):>6s} {steps}\n"
)
if row.get("error"):
output.write(f"{'':21s}error: {str(row['error'])[:160]}\n")
return EXIT_SUCCESS
if command == "cookies":
output.write(
f"{'observed':19s} {'account':24s} {'state':14s} {'probe':14s} "
f"{'fingerprint':12s} expires_at\n"
)
for row in rows:
output.write(
f"{_format_timestamp(row.get('observed_at')):19s} "
f"{str(row.get('account_id', ''))[:24]:24s} "
f"{str(row.get('state', '')):14s} "
f"{str(row.get('probe_status', '')):14s} "
f"{str(row.get('fingerprint', ''))[:12]:12s} "
f"{_format_timestamp(row.get('declared_expires_at'))}\n"
)
return EXIT_SUCCESS
# cookie-stats / 单账号 lifetime
if arguments.account:
output.write(
f"{'fingerprint':12s} {'first_seen':19s} {'last_healthy':19s} "
f"{'failed_at':19s} {'lifetime':>9s} {'declared':19s}\n"
)
for row in rows:
output.write(
f"{str(row.get('fingerprint', ''))[:12]:12s} "
f"{_format_timestamp(row.get('first_seen_at')):19s} "
f"{_format_timestamp(row.get('last_healthy_at')):19s} "
f"{_format_timestamp(row.get('first_failed_at')):19s} "
f"{_format_seconds(row.get('lifetime_seconds')):>9s} "
f"{_format_timestamp(row.get('declared_expires_at')):19s}\n"
)
return EXIT_SUCCESS
output.write(
f"{'account':24s} {'sessions':>8s} {'expired':>7s} {'avg':>7s} "
f"{'median':>7s} {'min':>7s} {'max':>7s} last_failed\n"
)
for row in rows:
output.write(
f"{str(row.get('account_id', ''))[:24]:24s} "
f"{row.get('sessions', 0):>8d} {row.get('expired_sessions', 0):>7d} "
f"{_format_seconds(row.get('avg_lifetime_seconds')):>7s} "
f"{_format_seconds(row.get('median_lifetime_seconds')):>7s} "
f"{_format_seconds(row.get('min_lifetime_seconds')):>7s} "
f"{_format_seconds(row.get('max_lifetime_seconds')):>7s} "
f"{_format_timestamp(row.get('last_failed_at'))}\n"
)
return EXIT_SUCCESS
def _list_workflows( def _list_workflows(
arguments: argparse.Namespace, arguments: argparse.Namespace,
registry: WorkflowRegistry, registry: WorkflowRegistry,
+85 -1
View File
@@ -147,6 +147,7 @@ class CookieGate:
self._now = now or (lambda: datetime.now(timezone.utc)) self._now = now or (lambda: datetime.now(timezone.utc))
self._sender = sender or send_lark_bot_message self._sender = sender or send_lark_bot_message
self._workflow_catalog: WorkflowCatalog | None = None self._workflow_catalog: WorkflowCatalog | None = None
self._audit_store: Any | None = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Account resolution # Account resolution
@@ -236,7 +237,7 @@ class CookieGate:
static_valid = _vault_cookie_valid(account, store) static_valid = _vault_cookie_valid(account, store)
status = _read_keepalive_status(account) status = _read_keepalive_status(account)
if static_valid and self._keepalive_success_is_fresh(status): if static_valid and self._keepalive_success_is_fresh(status):
self._write_state(account, STATE_HEALTHY, probe="fresh-keepalive") self._write_state(account, STATE_HEALTHY, probe="fresh-keepalive", context=context)
return GateVerdict( return GateVerdict(
account_id=account_id, account_id=account_id,
mode=mode, mode=mode,
@@ -269,6 +270,7 @@ class CookieGate:
STATE_HEALTHY, STATE_HEALTHY,
probe="success", probe="success",
recovered=recovered, recovered=recovered,
context=context,
) )
message = ( message = (
f"账号 {account_id} 登录态有效(自动账密重登成功)" f"账号 {account_id} 登录态有效(自动账密重登成功)"
@@ -367,6 +369,20 @@ class CookieGate:
state.get("pending_reruns"), context, _now_iso(self._now) state.get("pending_reruns"), context, _now_iso(self._now)
) )
self._save_state(account, state) self._save_state(account, state)
try:
keepalive_status = _read_keepalive_status(account) or {}
except Exception: # noqa: BLE001 - state file is advisory
keepalive_status = {}
self._audit_cookie(
account,
"not_logged_in"
if str(keepalive_status.get("status", "")) == "not_logged_in"
else "expired",
probe_status=str(keepalive_status.get("status", "expired")),
probe_source="gate",
detail=detail,
context=context,
)
message = _expiry_message(account_id, mode, context, detail) message = _expiry_message(account_id, mode, context, detail)
if notified: if notified:
message += " 已通过飞书通知登录负责人。" message += " 已通过飞书通知登录负责人。"
@@ -387,7 +403,17 @@ class CookieGate:
*, *,
probe: str, probe: str,
recovered: bool = False, recovered: bool = False,
context: RunContext | None = None,
) -> None: ) -> None:
if state_name == STATE_HEALTHY:
# 健康心跳要落在每一次判定上,才能算准「最后一次可用」的时间。
self._audit_cookie(
account,
"healthy",
probe_status=probe,
probe_source="gate",
context=context,
)
state = self._read_state(account) state = self._read_state(account)
if ( if (
state_name == STATE_HEALTHY state_name == STATE_HEALTHY
@@ -431,6 +457,64 @@ class CookieGate:
except OSError: # pragma: no cover - state file is advisory only except OSError: # pragma: no cover - state file is advisory only
pass pass
# ------------------------------------------------------------------
# Cookie telemetry (best-effort; never blocks or fails the gate)
# ------------------------------------------------------------------
def _audit(self) -> Any | None:
if self._audit_store is None:
try:
from gyxx_flow.ops.audit import AuditStore
self._audit_store = AuditStore(
self.settings.data_root,
environment=self._environment,
)
except Exception: # noqa: BLE001 - telemetry is optional
return None
return self._audit_store
def _audit_cookie(
self,
account: Any,
state: str,
*,
probe_status: str,
probe_source: str,
detail: str = "",
context: RunContext | None = None,
) -> None:
"""Append one cookie observation.
Stores a value-free fingerprint plus the earliest declared expiry of the
required cookies, so failure patterns (how long a login really lasts,
and whether it dies before or after the declared expiry) can be derived
from history instead of guessed. Errors are swallowed: gating must
never depend on the audit database.
"""
try:
store = self._audit()
if store is None or not store.enabled:
return
store.record_cookie_event(
account_id=str(getattr(account, "account_id", "") or ""),
state=state,
snapshot=self._cookie_snapshot(account),
probe_status=probe_status,
probe_source=probe_source,
detail=detail,
workflow_id=str(getattr(context, "workflow_id", "") or ""),
run_id=str(getattr(context, "run_id", "") or ""),
)
except Exception: # noqa: BLE001 - telemetry is advisory only
return
def _cookie_snapshot(self, account: Any) -> Any | None:
from gyxx_flow.ops.audit import snapshot_for_account
return snapshot_for_account(account)
def _within_notify_interval(self, state: Mapping[str, object]) -> bool: def _within_notify_interval(self, state: Mapping[str, object]) -> bool:
raw = state.get("last_notified_at") raw = state.get("last_notified_at")
if not isinstance(raw, str): if not isinstance(raw, str):
+117
View File
@@ -0,0 +1,117 @@
# 运维审计库(ops_* 表)
工作流执行历史与登录 cookie 遥测的 PostgreSQL 落库。本地 `var/runs/**/run.json`
仍是权威源,这里做的是**跨机器可查、可长期留存的关系型镜像**。
## 为什么需要
- 本地 journal 只在部署机磁盘上:换机、扩容、清理后历史就断了。
- `var/logs` 是文本,做不了「近 30 天哪些工作流失败最多」这类查询。
- cookie 状态原先只存在 `keepalive_status.json`(只有最新值),无法回答
「登录态到底能活几天」。
## 表与视图
| 对象 | 粒度 | 用途 |
|---|---|---|
| `ops_workflow_runs` | 一次运行一行 | 状态、模式、耗时、步骤计数、错误、journal 路径、主机 |
| `ops_workflow_run_steps` | 一次尝试一行 | 含重试;`exit_code`、错误、耗时 |
| `ops_account_cookie_events` | 一次观测一行 | 事件流;只存指纹,不存 cookie 明文 |
| `ops_account_cookie_lifetimes`(视图) | 一份 cookie 一行 | 从首次出现到失效的寿命 |
| `ops_account_cookie_stats`(视图) | 一个账号一行 | 平均 / 中位 / 最短 / 最长寿命 |
## 自动建表
首次写入时执行 `SCHEMA_SQL`(幂等 DDL),无需手工迁移。也可以用:
```bash
uv run gyxx audit schema > 001_ops_audit.sql # 导出 DDL 交 DBA 预建
psql "$GYXX_POSTGRES_DSN" -f 001_ops_audit.sql
```
## 查询
```bash
uv run gyxx audit runs --workflow content.metrics.self_operated.daily --limit 20
uv run gyxx audit runs --status failed --limit 50
uv run gyxx audit cookies --account douyin-shop --limit 50
uv run gyxx audit cookie-stats # 各账号失效规律
uv run gyxx audit cookie-stats --account douyin-shop # 单账号每次的寿命明细
```
都支持 `--json`,方便接到面板或告警脚本。
直接 SQL 也一样:
```sql
-- 最近 7 天失败最多的 10 个工作流
SELECT workflow_id, COUNT(*) AS failures
FROM ops_workflow_runs
WHERE status = 'failed' AND started_at > NOW() - INTERVAL '7 days'
GROUP BY workflow_id ORDER BY failures DESC LIMIT 10;
-- 每次失败卡在哪个步骤
SELECT r.workflow_id, s.step_id, COUNT(*) AS n
FROM ops_workflow_run_steps s
JOIN ops_workflow_runs r USING (run_id)
WHERE s.status = 'failed' AND r.started_at > NOW() - INTERVAL '7 days'
GROUP BY 1, 2 ORDER BY n DESC;
-- 哪个账号的登录态最短命(预警阈值参考)
SELECT account_id, sessions, expired_sessions,
avg_lifetime_seconds / 86400.0 AS avg_days,
min_lifetime_seconds / 86400.0 AS min_days,
last_failed_at
FROM ops_account_cookie_stats ORDER BY min_lifetime_seconds NULLS LAST;
```
## 怎么读「失效规律」
一次 cookie 代由 `fingerprint` 标识(需求域名/名称/值哈希排序后取摘要,与文件顺序无关):
- `first_healthy_at` → `first_failed_at` = `lifetime_seconds`:**实际可用时长**
- `healthy_to_failure_seconds`:最后一次探活成功到发现失效,反映**探测精度**
- `declared_expires_at`:cookie 自带的最早过期时间
把 `lifetime_seconds` 和 `declared_expires_at` 对比,能区分三种情况:
1. 实际寿命接近声明寿命 → 平台按声明失效,可按 `declared_expires_at` 提前预警;
2. 实际远短于声明 → 服务端提前吊销(常见于多端登录、风控);
3. `declared_expires_at` 为空(session cookie)→ 只能靠观测,`min_lifetime_seconds`
就是安全刷新周期的上界。
`fingerprint` 变化表示换了一份 cookie(重新登录),不算失效,也不会把两段寿命混在一起。
## 采集点
| 来源 | 触发 | `probe_source` |
|---|---|---|
| `CookieGate.check` | 每次工作流运行前的门禁判定 | `gate` |
| `AccountKeepaliveManager` | 常驻调度器每 `keepalive_interval_seconds`(默认 600s) | `keepalive` |
| `RunIndex.index_journal` | 每次运行开始/结束/异常 | — |
keepalive 是主要的精度来源:默认 10 分钟一次,同类状态一小时内只落一行,
状态翻转立即落行,所以「首次失效时间」误差不超过一个探活周期。
## 容错
审计**永不阻断业务**:
- 数据库不可达 / 缺 DSN / 建表失败 → 写入返回 `False`,调用方继续执行;
- 连续失败 3 次后熔断冷却(默认 300s),避免每个工作流都卡在连接超时;
- 失败记录写到 `<GYXX_DATA_ROOT>/logs/ops-audit/audit-failures.log`。
## 开关
| 变量 | 默认 | 说明 |
|---|---|---|
| `GYXX_OPS_AUDIT` | 开 | 设 `off` 关闭全部审计写入 |
| `GYXX_OPS_AUDIT_HOST` | 主机名 | 多机部署时写入手工标识 |
| `GYXX_OPS_AUDIT_FAILURE_COOLDOWN` | 300 | 熔断冷却秒数 |
## 不存什么
- **cookie 值**:只存哈希指纹;排查具体 cookie 请走 vault 文件。
- **日志正文**:`ops_workflow_runs` 只存 `journal_path` 指向本地文件;
完整堆栈仍在 `var/logs/console-restarts/`。
- **凭据**:DSN 只从 `GYXX_POSTGRES_DSN`(或 `PG_*`)读,不落表。
+5 -1
View File
@@ -1,5 +1,6 @@
"""Operational run indexing and durable outbox services.""" """Operational run indexing, audit telemetry and durable outbox services."""
from .audit import AuditStore, CookieSnapshot, summarize_cookies
from .effects import ( from .effects import (
EffectAlreadyApplied, EffectAlreadyApplied,
EffectClaim, EffectClaim,
@@ -10,6 +11,8 @@ from .index import RunIndex, RunRecord
from .outbox import IdempotencyConflict, Outbox, OutboxMessage from .outbox import IdempotencyConflict, Outbox, OutboxMessage
__all__ = [ __all__ = [
"AuditStore",
"CookieSnapshot",
"IdempotencyConflict", "IdempotencyConflict",
"EffectAlreadyApplied", "EffectAlreadyApplied",
"EffectClaim", "EffectClaim",
@@ -19,4 +22,5 @@ __all__ = [
"OutboxMessage", "OutboxMessage",
"RunIndex", "RunIndex",
"RunRecord", "RunRecord",
"summarize_cookies",
] ]
+901
View File
@@ -0,0 +1,901 @@
"""PostgreSQL-backed operational audit: workflow run history and cookie telemetry.
设计原则
--------
1. **可观测性不拖累业务**:审计写入永远不向调用方抛异常。数据库不可达时
只记录一条 failure 日志并熔断冷却,工作流照常执行。
2. **本地 journal 仍是权威源**:本模块是镜像/汇总,供跨机器查询与长期留存;
`var/runs/**/run.json` 不会被替代。
3. **不落明文凭据**:cookie 只存指纹(域名+名称+值哈希),值本身永不入库。
环境开关
--------
``GYXX_OPS_AUDIT=off`` 关闭审计(默认开启)
``GYXX_OPS_AUDIT_HOST`` 覆盖主机标识(默认 socket.gethostname())
``GYXX_OPS_AUDIT_FAILURE_COOLDOWN`` 熔断冷却秒数(默认 300)
``GYXX_POSTGRES_DSN`` 与动态配置共用同一 DSN
"""
from __future__ import annotations
import hashlib
import json
import os
import socket
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Iterator, Mapping, Sequence
SCHEMA_VERSION = 1
OPS_AUDIT_ENV = "GYXX_OPS_AUDIT"
OPS_AUDIT_HOST_ENV = "GYXX_OPS_AUDIT_HOST"
OPS_AUDIT_COOLDOWN_ENV = "GYXX_OPS_AUDIT_FAILURE_COOLDOWN"
_DEFAULT_COOLDOWN_SECONDS = 300
_MAX_CONSECUTIVE_FAILURES = 3
_MAX_TEXT_CHARS = 4000
_SCHEMA_LOCK_KEY = "gyxx:ops-audit:migration"
HEALTHY_STATES = frozenset({"healthy"})
FAILED_STATES = frozenset({"expired", "not_logged_in"})
_COOKIE_STATES = frozenset(
{"healthy", "expired", "not_logged_in", "probe_error", "busy", "disabled"}
)
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS ops_workflow_runs (
run_id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
business_date DATE NOT NULL,
mode TEXT NOT NULL DEFAULT 'unknown',
shadow BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
error TEXT,
step_total INTEGER NOT NULL DEFAULT 0,
step_success INTEGER NOT NULL DEFAULT 0,
step_failed INTEGER NOT NULL DEFAULT 0,
step_skipped INTEGER NOT NULL DEFAULT 0,
step_running INTEGER NOT NULL DEFAULT 0,
journal_path TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ops_workflow_runs_workflow_idx
ON ops_workflow_runs (workflow_id, started_at DESC);
CREATE INDEX IF NOT EXISTS ops_workflow_runs_status_idx
ON ops_workflow_runs (status, started_at DESC);
CREATE INDEX IF NOT EXISTS ops_workflow_runs_business_date_idx
ON ops_workflow_runs (business_date, workflow_id);
CREATE TABLE IF NOT EXISTS ops_workflow_run_steps (
run_id TEXT NOT NULL
REFERENCES ops_workflow_runs (run_id) ON DELETE CASCADE,
step_id TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT '',
exit_code INTEGER,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
error TEXT,
PRIMARY KEY (run_id, step_id, attempt)
);
CREATE INDEX IF NOT EXISTS ops_workflow_run_steps_step_idx
ON ops_workflow_run_steps (step_id, status);
CREATE TABLE IF NOT EXISTS ops_account_cookie_events (
id BIGSERIAL PRIMARY KEY,
account_id TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
state TEXT NOT NULL,
probe_status TEXT NOT NULL DEFAULT '',
probe_source TEXT NOT NULL DEFAULT '',
cookie_count INTEGER,
required_count INTEGER,
fingerprint TEXT NOT NULL DEFAULT '',
declared_expires_at TIMESTAMPTZ,
detail TEXT NOT NULL DEFAULT '',
workflow_id TEXT NOT NULL DEFAULT '',
run_id TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS ops_account_cookie_events_account_idx
ON ops_account_cookie_events (account_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS ops_account_cookie_events_state_idx
ON ops_account_cookie_events (state, observed_at DESC);
CREATE INDEX IF NOT EXISTS ops_account_cookie_events_fingerprint_idx
ON ops_account_cookie_events (account_id, fingerprint);
-- 一段「cookie 代」的生命周期:同一 fingerprint 从首次出现到失效。
-- first_failed_at 只取最后一次 healthy 之后的失败,避免恢复后再次失败时口径前移。
CREATE OR REPLACE VIEW ops_account_cookie_lifetimes AS
WITH per_fingerprint AS (
SELECT
account_id,
fingerprint,
MIN(observed_at) AS first_seen_at,
MIN(observed_at) FILTER (WHERE state = 'healthy') AS first_healthy_at,
MAX(observed_at) FILTER (WHERE state = 'healthy') AS last_healthy_at,
MAX(declared_expires_at) AS declared_expires_at
FROM ops_account_cookie_events
WHERE fingerprint <> ''
GROUP BY account_id, fingerprint
)
SELECT
p.account_id,
p.fingerprint,
p.first_seen_at,
p.first_healthy_at,
p.last_healthy_at,
p.declared_expires_at,
f.first_failed_at,
(f.first_failed_at IS NOT NULL) AS expired,
CASE
WHEN p.first_healthy_at IS NOT NULL AND p.last_healthy_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (p.last_healthy_at - p.first_healthy_at))::BIGINT
END AS healthy_span_seconds,
CASE
WHEN p.first_healthy_at IS NOT NULL AND f.first_failed_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (f.first_failed_at - p.first_healthy_at))::BIGINT
END AS lifetime_seconds,
CASE
WHEN p.last_healthy_at IS NOT NULL AND f.first_failed_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (f.first_failed_at - p.last_healthy_at))::BIGINT
END AS healthy_to_failure_seconds
FROM per_fingerprint p
LEFT JOIN LATERAL (
SELECT MIN(e.observed_at) AS first_failed_at
FROM ops_account_cookie_events e
WHERE e.account_id = p.account_id
AND e.fingerprint = p.fingerprint
AND e.state IN ('expired', 'not_logged_in')
AND (p.last_healthy_at IS NULL OR e.observed_at > p.last_healthy_at)
) f ON TRUE;
-- 按账号汇总的失效规律:平均/中位/最短/最长寿命,直接支撑「多久失效一次」。
CREATE OR REPLACE VIEW ops_account_cookie_stats AS
SELECT
account_id,
COUNT(*) AS sessions,
COUNT(*) FILTER (WHERE expired) AS expired_sessions,
AVG(lifetime_seconds)::BIGINT AS avg_lifetime_seconds,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY lifetime_seconds)::BIGINT
AS median_lifetime_seconds,
MIN(lifetime_seconds) AS min_lifetime_seconds,
MAX(lifetime_seconds) AS max_lifetime_seconds,
MAX(first_failed_at) AS last_failed_at,
MAX(last_healthy_at) AS last_healthy_at
FROM ops_account_cookie_lifetimes
WHERE lifetime_seconds IS NOT NULL
GROUP BY account_id;
CREATE TABLE IF NOT EXISTS ops_audit_schema (
schema_version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""
# ----------------------------------------------------------------------
# 纯函数:cookie 快照
# ----------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class CookieSnapshot:
"""一次静态检查得到的 cookie 画像,不含任何明文值。"""
cookie_count: int
required_count: int
fingerprint: str
declared_expires_at: datetime | None
def to_dict(self) -> dict[str, Any]:
return {
"cookie_count": self.cookie_count,
"required_count": self.required_count,
"fingerprint": self.fingerprint,
"declared_expires_at": (
self.declared_expires_at.isoformat()
if self.declared_expires_at is not None
else None
),
}
def _domain_matches(domain: str, required_domains: Sequence[str]) -> bool:
normalized = str(domain or "").lstrip(".").casefold()
if not normalized:
return False
return any(
normalized == required.casefold() or normalized.endswith("." + required.casefold())
for required in required_domains
)
def _expires_at(cookie: Mapping[str, Any]) -> datetime | None:
raw = cookie.get("expires")
if raw is None or isinstance(raw, bool):
return None
try:
value = float(raw)
except (TypeError, ValueError):
return None
# Playwright 用 -1 表示 session cookie;0 与负数都视为「不声明」。
if value <= 0:
return None
try:
return datetime.fromtimestamp(value, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
def summarize_cookies(
cookies: Sequence[Mapping[str, Any]],
*,
required_domains: Sequence[str] = (),
required_names: Sequence[str] = (),
) -> CookieSnapshot:
"""Build a value-free snapshot: counts, fingerprint and earliest declared expiry.
指纹只看「必需 cookie」的域名/名称/值哈希,因此登录态真正换了一份 cookie
时指纹会变,而无关 cookie 的增删不会造成抖动。
"""
required_domains = tuple(item for item in required_domains if item)
required_names = tuple(item for item in required_names if item)
components: list[str] = []
required_count = 0
earliest: datetime | None = None
for cookie in cookies:
if not isinstance(cookie, Mapping):
continue
name = str(cookie.get("name", ""))
domain = str(cookie.get("domain", ""))
is_required = bool(required_domains) and _domain_matches(domain, required_domains)
if required_names:
is_required = is_required and name in required_names
elif not required_domains:
is_required = True
if not is_required:
continue
required_count += 1
value = str(cookie.get("value", ""))
value_hash = hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:16]
components.append(f"{domain.lstrip('.')}|{name}|{value_hash}")
expiry = _expires_at(cookie)
if expiry is not None and (earliest is None or expiry < earliest):
earliest = expiry
# 浏览器写入 cookie 的顺序并不稳定;排序后哈希才能保证同一份登录态得到同一指纹。
digest = hashlib.sha256()
for component in sorted(components):
digest.update(f"{component}\n".encode("utf-8"))
return CookieSnapshot(
cookie_count=len(cookies),
required_count=required_count,
fingerprint=digest.hexdigest()[:32] if required_count else "",
declared_expires_at=earliest,
)
def snapshot_for_account(account: Any) -> CookieSnapshot | None:
"""Read an account's cookie vault and summarise it, or ``None`` when unreadable.
Shared by the gate and the keepalive manager so both record the exact same
fingerprint for the same vault contents.
"""
cookie_file = getattr(account, "cookie_file", None)
if cookie_file is None:
return None
try:
from gyxx_flow.adapters.browser import BrowserCookieStore
store = BrowserCookieStore(
cookie_file,
getattr(account, "storage_state_file", cookie_file),
)
cookies = store.load_cookies()
except Exception: # noqa: BLE001 - a missing/corrupt vault is itself a signal
return None
return summarize_cookies(
cookies,
required_domains=tuple(getattr(account, "required_cookie_domains", ()) or ()),
required_names=tuple(getattr(account, "required_cookie_names", ()) or ()),
)
# ----------------------------------------------------------------------
# 时间与文本工具
# ----------------------------------------------------------------------
def _parse_datetime(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
if not isinstance(value, str) or not value.strip():
return None
text = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _parse_date(value: Any) -> date | None:
if isinstance(value, date) and not isinstance(value, datetime):
return value
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _seconds_between(start: datetime | None, end: datetime | None) -> int | None:
if start is None or end is None:
return None
delta = (end - start).total_seconds()
return max(0, int(delta))
def _clip(value: Any, limit: int = _MAX_TEXT_CHARS) -> str:
if value is None:
return ""
text = value if isinstance(value, str) else str(value)
return text[:limit]
def split_step_key(key: str) -> tuple[str, int]:
"""``sync.attempt-2`` -> ``("sync", 2)``; 无法解析时原样当作 step_id。"""
if ".attempt-" in key:
name, _, raw = key.rpartition(".attempt-")
try:
attempt = int(raw)
except ValueError:
return key, 1
if name and attempt > 0:
return name, attempt
return key, 1
# ----------------------------------------------------------------------
# 审计存储
# ----------------------------------------------------------------------
class AuditStore:
"""Idempotent writer for the ``ops_*`` tables.
所有公开写入方法都返回 bool,并且**永不抛出**异常;数据库问题只会导致本次
审计丢失,不会影响工作流执行。
"""
def __init__(
self,
data_root: Path | str,
*,
environment: Mapping[str, str] | None = None,
connect: Any | None = None,
clock: Any | None = None,
) -> None:
self._data_root = Path(data_root).expanduser().resolve()
self._environment = dict(os.environ if environment is None else environment)
self._connect_impl = connect
self._clock = clock or (lambda: datetime.now(timezone.utc))
self._schema_ready = False
self._consecutive_failures = 0
self._cooldown_until = 0.0
self._last_error = ""
self._failure_log = self._data_root / "logs" / "ops-audit" / "audit-failures.log"
# -- 状态 ---------------------------------------------------------
@property
def enabled(self) -> bool:
raw = str(self._environment.get(OPS_AUDIT_ENV, "")).strip().lower()
return raw not in {"0", "off", "false", "no", "disabled"}
@property
def last_error(self) -> str:
return self._last_error
def _host(self) -> str:
override = str(self._environment.get(OPS_AUDIT_HOST_ENV, "")).strip()
if override:
return override
try:
return socket.gethostname()[:128]
except OSError: # pragma: no cover - 极端环境
return ""
def _cooldown_seconds(self) -> int:
raw = str(self._environment.get(OPS_AUDIT_COOLDOWN_ENV, "")).strip()
try:
return max(0, int(raw))
except ValueError:
return _DEFAULT_COOLDOWN_SECONDS
def _in_cooldown(self) -> bool:
import time
return time.monotonic() < self._cooldown_until
# -- 连接 ---------------------------------------------------------
@contextmanager
def _connection(self) -> Iterator[Any]:
if self._connect_impl is not None:
conn = self._connect_impl()
try:
yield conn
finally:
close = getattr(conn, "close", None)
if callable(close):
close()
return
import psycopg # 延迟导入:未安装 psycopg 时整个模块仍可导入
from psycopg.rows import dict_row
dsn, kwargs = database_kwargs(self._environment)
if dsn:
conn = psycopg.connect(dsn, row_factory=dict_row)
else:
conn = psycopg.connect(**kwargs, row_factory=dict_row)
try:
yield conn
finally:
conn.close()
def _ensure_schema(self, conn: Any) -> None:
"""Create the audit objects once, serialising the first DDL pass.
Several workflows can start at the same moment; running CREATE/COMMENT
concurrently lets sessions take DDL locks in different orders and can
deadlock. The advisory lock is only taken when the schema is missing,
so steady-state processes skip the round trip entirely.
"""
if self._schema_ready:
return
with conn.cursor() as cursor:
cursor.execute(
"SELECT 1 FROM pg_class "
"WHERE relname = 'ops_audit_schema' AND relkind = 'r'"
)
initialised = cursor.fetchone() is not None
if not initialised:
with conn.cursor() as cursor:
cursor.execute(
"SELECT pg_advisory_xact_lock(hashtext(%s))",
(_SCHEMA_LOCK_KEY,),
)
cursor.execute(SCHEMA_SQL)
cursor.execute(
"INSERT INTO ops_audit_schema (schema_version) VALUES (%s) "
"ON CONFLICT (schema_version) DO NOTHING",
(SCHEMA_VERSION,),
)
conn.commit()
self._schema_ready = True
# -- 失败处理 -----------------------------------------------------
def _record_failure(self, operation: str, exc: BaseException) -> None:
import time
self._consecutive_failures += 1
self._last_error = f"{type(exc).__name__}: {exc}"[:300]
if self._consecutive_failures >= _MAX_CONSECUTIVE_FAILURES:
self._cooldown_until = time.monotonic() + self._cooldown_seconds()
try:
self._failure_log.parent.mkdir(parents=True, exist_ok=True)
payload = {
"at": self._clock().isoformat(timespec="seconds"),
"operation": operation,
"error": self._last_error,
"consecutive_failures": self._consecutive_failures,
}
with self._failure_log.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(payload, ensure_ascii=False) + "\n")
except OSError:
pass
def _run_guarded(self, operation: str, action: Any) -> bool:
if not self.enabled or self._in_cooldown():
return False
try:
with self._connection() as conn:
self._ensure_schema(conn)
action(conn)
self._consecutive_failures = 0
return True
except Exception as exc: # noqa: BLE001 - 审计绝不影响业务
self._record_failure(operation, exc)
return False
# -- 运行记录 -----------------------------------------------------
def record_run(
self,
record: Mapping[str, Any],
steps: Mapping[str, Any] | None = None,
) -> bool:
"""Upsert one run and its steps from a journal payload or RunRecord dict."""
workflow_id = _clip(record.get("workflow_id"), 200)
run_id = _clip(record.get("run_id"), 200)
business_date = _parse_date(record.get("business_date"))
if not workflow_id or not run_id or business_date is None:
return False
status = _clip(record.get("status"), 32) or "unknown"
mode = _clip(record.get("mode"), 32) or "unknown"
started_at = _parse_datetime(record.get("started_at"))
ended_at = _parse_datetime(record.get("ended_at"))
step_rows = self._step_rows(run_id, steps)
counts = self._step_counts(steps, record)
def action(conn: Any) -> None:
with conn.cursor() as cursor:
cursor.execute(
"""
INSERT INTO ops_workflow_runs (
run_id, workflow_id, business_date, mode, shadow, status,
started_at, ended_at, duration_seconds, error,
step_total, step_success, step_failed, step_skipped,
step_running, journal_path, host, indexed_at
) VALUES (
%(run_id)s, %(workflow_id)s, %(business_date)s, %(mode)s,
%(shadow)s, %(status)s, %(started_at)s, %(ended_at)s,
%(duration_seconds)s, %(error)s, %(step_total)s,
%(step_success)s, %(step_failed)s, %(step_skipped)s,
%(step_running)s, %(journal_path)s, %(host)s, NOW()
)
ON CONFLICT (run_id) DO UPDATE SET
status = EXCLUDED.status,
ended_at = EXCLUDED.ended_at,
duration_seconds = EXCLUDED.duration_seconds,
error = EXCLUDED.error,
step_total = EXCLUDED.step_total,
step_success = EXCLUDED.step_success,
step_failed = EXCLUDED.step_failed,
step_skipped = EXCLUDED.step_skipped,
step_running = EXCLUDED.step_running,
journal_path = EXCLUDED.journal_path,
indexed_at = NOW()
""",
{
"run_id": run_id,
"workflow_id": workflow_id,
"business_date": business_date,
"mode": mode,
"shadow": bool(record.get("shadow", False)),
"status": status,
"started_at": started_at,
"ended_at": ended_at,
"duration_seconds": _seconds_between(started_at, ended_at),
"error": _clip(record.get("error")),
"step_total": counts["total"],
"step_success": counts["success"],
"step_failed": counts["failed"],
"step_skipped": counts["skipped"],
"step_running": counts["running"],
"journal_path": _clip(record.get("journal_path"), 1000),
"host": self._host(),
},
)
if step_rows:
cursor.executemany(
"""
INSERT INTO ops_workflow_run_steps (
run_id, step_id, attempt, status, exit_code,
started_at, ended_at, duration_seconds, error
) VALUES (
%(run_id)s, %(step_id)s, %(attempt)s, %(status)s,
%(exit_code)s, %(started_at)s, %(ended_at)s,
%(duration_seconds)s, %(error)s
)
ON CONFLICT (run_id, step_id, attempt) DO UPDATE SET
status = EXCLUDED.status,
exit_code = EXCLUDED.exit_code,
started_at = COALESCE(
ops_workflow_run_steps.started_at,
EXCLUDED.started_at
),
ended_at = EXCLUDED.ended_at,
duration_seconds = EXCLUDED.duration_seconds,
error = EXCLUDED.error
""",
step_rows,
)
conn.commit()
return self._run_guarded("record_run", action)
@staticmethod
def _step_rows(
run_id: str, steps: Mapping[str, Any] | None
) -> list[dict[str, Any]]:
if not isinstance(steps, Mapping):
return []
rows: list[dict[str, Any]] = []
for key, raw in steps.items():
if not isinstance(raw, Mapping):
continue
step_id, attempt = split_step_key(str(key))
attempt_value = raw.get("attempt")
if isinstance(attempt_value, int) and not isinstance(attempt_value, bool):
attempt = max(1, attempt_value)
started_at = _parse_datetime(raw.get("started_at"))
ended_at = _parse_datetime(raw.get("ended_at"))
exit_code = raw.get("exit_code")
rows.append(
{
"run_id": run_id,
"step_id": _clip(step_id, 200),
"attempt": attempt,
"status": _clip(raw.get("status"), 32),
"exit_code": exit_code if isinstance(exit_code, int) else None,
"started_at": started_at,
"ended_at": ended_at,
"duration_seconds": _seconds_between(started_at, ended_at),
"error": _clip(raw.get("error")),
}
)
return rows
@staticmethod
def _step_counts(
steps: Mapping[str, Any] | None, record: Mapping[str, Any]
) -> dict[str, int]:
counts = {"total": 0, "success": 0, "failed": 0, "skipped": 0, "running": 0}
if isinstance(steps, Mapping) and steps:
for raw in steps.values():
if not isinstance(raw, Mapping):
continue
status = str(raw.get("status", ""))
counts["total"] += 1
if status in counts:
counts[status] += 1
return counts
# 回退到 RunRecord 的 step_counts 投影
projection = record.get("step_counts")
if isinstance(projection, Mapping):
for name in ("success", "failed", "skipped", "running"):
value = projection.get(name)
if isinstance(value, int) and not isinstance(value, bool):
counts[name] = max(0, value)
counts["total"] = counts["success"] + counts["failed"] + counts["skipped"] + counts["running"]
return counts
# -- cookie 事件 ---------------------------------------------------
def record_cookie_event(
self,
*,
account_id: str,
state: str,
snapshot: CookieSnapshot | None = None,
probe_status: str = "",
probe_source: str = "",
detail: str = "",
workflow_id: str = "",
run_id: str = "",
observed_at: datetime | None = None,
) -> bool:
"""Append one observation. 状态必须是白名单值,避免脏数据污染统计。"""
account_id = _clip(account_id, 200)
if not account_id:
return False
state = str(state or "").strip()
if state not in _COOKIE_STATES:
raise ValueError(f"invalid cookie state: {state!r}")
def action(conn: Any) -> None:
with conn.cursor() as cursor:
cursor.execute(
"""
INSERT INTO ops_account_cookie_events (
account_id, observed_at, state, probe_status, probe_source,
cookie_count, required_count, fingerprint, declared_expires_at,
detail, workflow_id, run_id, host
) VALUES (
%(account_id)s, %(observed_at)s, %(state)s, %(probe_status)s,
%(probe_source)s, %(cookie_count)s, %(required_count)s,
%(fingerprint)s, %(declared_expires_at)s, %(detail)s,
%(workflow_id)s, %(run_id)s, %(host)s
)
""",
{
"account_id": account_id,
"observed_at": observed_at or self._clock(),
"state": state,
"probe_status": _clip(probe_status, 64),
"probe_source": _clip(probe_source, 64),
"cookie_count": snapshot.cookie_count if snapshot else None,
"required_count": snapshot.required_count if snapshot else None,
"fingerprint": snapshot.fingerprint if snapshot else "",
"declared_expires_at": (
snapshot.declared_expires_at if snapshot else None
),
"detail": _clip(detail),
"workflow_id": _clip(workflow_id, 200),
"run_id": _clip(run_id, 200),
"host": self._host(),
},
)
conn.commit()
return self._run_guarded("record_cookie_event", action)
# -- 查询(供 CLI) ------------------------------------------------
def query_runs(
self,
*,
workflow_id: str | None = None,
status: str | None = None,
business_date: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
conditions: list[str] = []
params: dict[str, Any] = {"limit": max(1, min(int(limit), 500))}
if workflow_id:
conditions.append("workflow_id = %(workflow_id)s")
params["workflow_id"] = workflow_id
if status:
conditions.append("status = %(status)s")
params["status"] = status
if business_date:
parsed = _parse_date(business_date)
if parsed is not None:
conditions.append("business_date = %(business_date)s")
params["business_date"] = parsed
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
sql = f"""
SELECT * FROM ops_workflow_runs
{where}
ORDER BY started_at DESC NULLS LAST, run_id DESC
LIMIT %(limit)s
"""
return self._fetch(sql, params)
def query_steps(self, run_id: str) -> list[dict[str, Any]]:
return self._fetch(
"""
SELECT * FROM ops_workflow_run_steps
WHERE run_id = %(run_id)s
ORDER BY step_id, attempt
""",
{"run_id": run_id},
)
def query_cookie_events(
self,
*,
account_id: str | None = None,
state: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
conditions: list[str] = []
params: dict[str, Any] = {"limit": max(1, min(int(limit), 500))}
if account_id:
conditions.append("account_id = %(account_id)s")
params["account_id"] = account_id
if state:
conditions.append("state = %(state)s")
params["state"] = state
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
sql = f"""
SELECT * FROM ops_account_cookie_events
{where}
ORDER BY observed_at DESC
LIMIT %(limit)s
"""
return self._fetch(sql, params)
def query_cookie_lifetimes(self, *, account_id: str | None = None) -> list[dict[str, Any]]:
if account_id:
return self._fetch(
"""
SELECT * FROM ops_account_cookie_lifetimes
WHERE account_id = %(account_id)s
ORDER BY first_seen_at DESC
""",
{"account_id": account_id},
)
return self._fetch(
"SELECT * FROM ops_account_cookie_lifetimes ORDER BY first_seen_at DESC",
{},
)
def query_cookie_stats(self) -> list[dict[str, Any]]:
return self._fetch(
"SELECT * FROM ops_account_cookie_stats ORDER BY account_id",
{},
)
def _fetch(self, sql: str, params: Mapping[str, Any]) -> list[dict[str, Any]]:
if not self.enabled or self._in_cooldown():
return []
try:
with self._connection() as conn:
self._ensure_schema(conn)
with conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
self._consecutive_failures = 0
return [dict(row) for row in rows]
except Exception as exc: # noqa: BLE001
self._record_failure("query", exc)
return []
# ----------------------------------------------------------------------
# 连接参数(与 PostgresWorkflowConfigStore 同口径)
# ----------------------------------------------------------------------
def database_kwargs(environment: Mapping[str, str]) -> tuple[str | None, dict[str, Any]]:
values = _apply_runtime_policy(environment)
dsn = str(values.get("GYXX_POSTGRES_DSN", "")).strip()
if dsn:
return dsn, {}
required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
missing = [name for name in required if not str(values.get(name, "")).strip()]
if missing:
raise RuntimeError("审计数据库配置缺失: " + ", ".join(missing))
try:
port = int(values["PG_PORT"])
except (TypeError, ValueError) as exc:
raise RuntimeError("PG_PORT 必须是整数") from exc
return None, {
"host": str(values["PG_HOST"]).strip(),
"port": port,
"dbname": str(values["PG_DB"]).strip(),
"user": str(values["PG_USER"]).strip(),
"password": str(values["PG_PASSWORD"]),
}
def _apply_runtime_policy(environment: Mapping[str, str]) -> Mapping[str, str]:
"""复用 RuntimeServicePolicy,保证与控制台/工作流看到同一个数据库地址。"""
try:
from gyxx_flow.adapters import RuntimeServicePolicy
except Exception: # pragma: no cover - 循环导入兜底
return environment
try:
return RuntimeServicePolicy().apply(dict(environment))
except Exception: # pragma: no cover
return environment
def default_store(data_root: Path | str | None = None) -> AuditStore:
"""Best-effort store resolved from the standard data root."""
if data_root is None:
try:
from gyxx_flow.core.config import Settings
data_root = Settings.from_env().data_root
except Exception: # pragma: no cover
data_root = Path.cwd() / "var"
return AuditStore(data_root)
+40
View File
@@ -86,6 +86,7 @@ class RunIndex:
resource = f"ops:run-index:{_digest(record.run_id)}" resource = f"ops:run-index:{_digest(record.run_id)}"
with self._locks.acquire(resource, owner=_owner(), timeout_seconds=30): with self._locks.acquire(resource, owner=_owner(), timeout_seconds=30):
atomic_write_json(destination, record.to_dict()) atomic_write_json(destination, record.to_dict())
_mirror_run_to_audit(self._data_root, record, raw)
return record return record
def get(self, run_id: str) -> RunRecord | None: def get(self, run_id: str) -> RunRecord | None:
@@ -255,3 +256,42 @@ def _digest(value: str) -> str:
import hashlib import hashlib
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:32] return hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
_AUDIT_STORES: dict[str, Any] = {}
def _audit_store_for(data_root: Path) -> Any:
"""Reuse one store per data root so schema bootstrap and the circuit breaker
survive the short-lived ``RunIndex`` instances created per run."""
key = str(data_root)
store = _AUDIT_STORES.get(key)
if store is None:
from gyxx_flow.ops.audit import AuditStore
store = AuditStore(data_root)
_AUDIT_STORES[key] = store
return store
def _mirror_run_to_audit(
data_root: Path,
record: RunRecord,
journal_payload: Any,
) -> None:
"""Best-effort mirror of a run into the PostgreSQL audit tables.
Auditing is strictly additive: any failure here (missing DSN, unreachable
database, unexpected schema state) is swallowed so that journal indexing and
the workflow itself are never affected.
"""
try:
store = _audit_store_for(data_root)
if not store.enabled:
return
steps = journal_payload.get("steps") if isinstance(journal_payload, dict) else None
store.record_run(record.to_dict(), steps)
except Exception: # noqa: BLE001 - audit mirroring must never raise
return
@@ -0,0 +1,152 @@
-- Migration 001: operational audit tables (workflow runs + login cookie telemetry).
--
-- 本文件由 src/gyxx_flow/ops/audit.py 的 SCHEMA_SQL 导出,请勿手工编辑;
-- 运行时首次写入会自动执行同一份 DDL(幂等),此处仅供 DBA 审阅或预建。
--
-- 用法: psql "$GYXX_POSTGRES_DSN" -f 001_ops_audit.sql
--
-- 说明:
-- ops_workflow_runs 一次工作流运行一行,run.json 的关系型投影
-- ops_workflow_run_steps 每次尝试一行(含重试),保留 exit_code 与错误
-- ops_account_cookie_events cookie 观测事件流,只存指纹不存明文值
-- ops_account_cookie_lifetimes 一处登录态从首次出现到失效(视图)
-- ops_account_cookie_stats 每账号失效规律汇总(视图)
--
-- 日志正文仍留在 var/logs 下,数据库只存结构化元数据与指向路径。
CREATE TABLE IF NOT EXISTS ops_workflow_runs (
run_id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
business_date DATE NOT NULL,
mode TEXT NOT NULL DEFAULT 'unknown',
shadow BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
error TEXT,
step_total INTEGER NOT NULL DEFAULT 0,
step_success INTEGER NOT NULL DEFAULT 0,
step_failed INTEGER NOT NULL DEFAULT 0,
step_skipped INTEGER NOT NULL DEFAULT 0,
step_running INTEGER NOT NULL DEFAULT 0,
journal_path TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ops_workflow_runs_workflow_idx
ON ops_workflow_runs (workflow_id, started_at DESC);
CREATE INDEX IF NOT EXISTS ops_workflow_runs_status_idx
ON ops_workflow_runs (status, started_at DESC);
CREATE INDEX IF NOT EXISTS ops_workflow_runs_business_date_idx
ON ops_workflow_runs (business_date, workflow_id);
CREATE TABLE IF NOT EXISTS ops_workflow_run_steps (
run_id TEXT NOT NULL
REFERENCES ops_workflow_runs (run_id) ON DELETE CASCADE,
step_id TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT '',
exit_code INTEGER,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
error TEXT,
PRIMARY KEY (run_id, step_id, attempt)
);
CREATE INDEX IF NOT EXISTS ops_workflow_run_steps_step_idx
ON ops_workflow_run_steps (step_id, status);
CREATE TABLE IF NOT EXISTS ops_account_cookie_events (
id BIGSERIAL PRIMARY KEY,
account_id TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
state TEXT NOT NULL,
probe_status TEXT NOT NULL DEFAULT '',
probe_source TEXT NOT NULL DEFAULT '',
cookie_count INTEGER,
required_count INTEGER,
fingerprint TEXT NOT NULL DEFAULT '',
declared_expires_at TIMESTAMPTZ,
detail TEXT NOT NULL DEFAULT '',
workflow_id TEXT NOT NULL DEFAULT '',
run_id TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS ops_account_cookie_events_account_idx
ON ops_account_cookie_events (account_id, observed_at DESC);
CREATE INDEX IF NOT EXISTS ops_account_cookie_events_state_idx
ON ops_account_cookie_events (state, observed_at DESC);
CREATE INDEX IF NOT EXISTS ops_account_cookie_events_fingerprint_idx
ON ops_account_cookie_events (account_id, fingerprint);
-- 一段「cookie 代」的生命周期:同一 fingerprint 从首次出现到失效。
-- first_failed_at 只取最后一次 healthy 之后的失败,避免恢复后再次失败时口径前移。
CREATE OR REPLACE VIEW ops_account_cookie_lifetimes AS
WITH per_fingerprint AS (
SELECT
account_id,
fingerprint,
MIN(observed_at) AS first_seen_at,
MIN(observed_at) FILTER (WHERE state = 'healthy') AS first_healthy_at,
MAX(observed_at) FILTER (WHERE state = 'healthy') AS last_healthy_at,
MAX(declared_expires_at) AS declared_expires_at
FROM ops_account_cookie_events
WHERE fingerprint <> ''
GROUP BY account_id, fingerprint
)
SELECT
p.account_id,
p.fingerprint,
p.first_seen_at,
p.first_healthy_at,
p.last_healthy_at,
p.declared_expires_at,
f.first_failed_at,
(f.first_failed_at IS NOT NULL) AS expired,
CASE
WHEN p.first_healthy_at IS NOT NULL AND p.last_healthy_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (p.last_healthy_at - p.first_healthy_at))::BIGINT
END AS healthy_span_seconds,
CASE
WHEN p.first_healthy_at IS NOT NULL AND f.first_failed_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (f.first_failed_at - p.first_healthy_at))::BIGINT
END AS lifetime_seconds,
CASE
WHEN p.last_healthy_at IS NOT NULL AND f.first_failed_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (f.first_failed_at - p.last_healthy_at))::BIGINT
END AS healthy_to_failure_seconds
FROM per_fingerprint p
LEFT JOIN LATERAL (
SELECT MIN(e.observed_at) AS first_failed_at
FROM ops_account_cookie_events e
WHERE e.account_id = p.account_id
AND e.fingerprint = p.fingerprint
AND e.state IN ('expired', 'not_logged_in')
AND (p.last_healthy_at IS NULL OR e.observed_at > p.last_healthy_at)
) f ON TRUE;
-- 按账号汇总的失效规律:平均/中位/最短/最长寿命,直接支撑「多久失效一次」。
CREATE OR REPLACE VIEW ops_account_cookie_stats AS
SELECT
account_id,
COUNT(*) AS sessions,
COUNT(*) FILTER (WHERE expired) AS expired_sessions,
AVG(lifetime_seconds)::BIGINT AS avg_lifetime_seconds,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY lifetime_seconds)::BIGINT
AS median_lifetime_seconds,
MIN(lifetime_seconds) AS min_lifetime_seconds,
MAX(lifetime_seconds) AS max_lifetime_seconds,
MAX(first_failed_at) AS last_failed_at,
MAX(last_healthy_at) AS last_healthy_at
FROM ops_account_cookie_lifetimes
WHERE lifetime_seconds IS NOT NULL
GROUP BY account_id;
CREATE TABLE IF NOT EXISTS ops_audit_schema (
schema_version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-1
View File
@@ -36,7 +36,6 @@ def test_repository_keeps_the_simplified_public_structure() -> None:
"note-inventory.md", "note-inventory.md",
"plan.md", "plan.md",
"runbook.md", "runbook.md",
"workbench.md",
"workflow-acceptance-test-report.md", "workflow-acceptance-test-report.md",
"workflow-acceptance-test-requirements.md", "workflow-acceptance-test-requirements.md",
"workflow-console.md", "workflow-console.md",
+359
View File
@@ -0,0 +1,359 @@
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
import pytest
from gyxx_flow.ops.audit import (
SCHEMA_SQL,
AuditStore,
split_step_key,
summarize_cookies,
)
from gyxx_flow.ops.index import RunIndex
DOUYIN_COOKIES: list[dict[str, Any]] = [
{
"name": "sessionid",
"value": "AAA",
"domain": ".jinritemai.com",
"expires": 1766000000.0,
},
{
"name": "sid_tt",
"value": "BBB",
"domain": ".jinritemai.com",
"expires": 1766100000.0,
},
{
"name": "unrelated",
"value": "CCC",
"domain": ".example.com",
"expires": 1700000000.0,
},
{
"name": "session",
"value": "DDD",
"domain": ".jinritemai.com",
"expires": -1,
},
]
class _FakeCursor:
def __init__(self, log: list[Any], rows: list[dict[str, Any]]) -> None:
self._log = log
self._rows = rows
def __enter__(self) -> "_FakeCursor":
return self
def __exit__(self, *_: object) -> bool:
return False
def execute(self, sql: str, params: Any = None) -> None:
self._log.append(("execute", sql, params))
def executemany(self, sql: str, rows: Any) -> None:
self._log.append(("executemany", sql, list(rows)))
def fetchall(self) -> list[dict[str, Any]]:
return self._rows
def fetchone(self) -> dict[str, Any] | None:
# 默认模拟「ops_audit_schema 尚不存在」,从而走首次建表分支。
return None
def close(self) -> None:
return None
class _FakeConnection:
def __init__(self, log: list[Any], rows: list[dict[str, Any]] | None = None) -> None:
self._log = log
self._rows = rows or []
def cursor(self) -> _FakeCursor:
return _FakeCursor(self._log, self._rows)
def commit(self) -> None:
self._log.append(("commit",))
def close(self) -> None:
self._log.append(("close",))
def _store_with_fake(
tmp_path: Path,
*,
environment: dict[str, str] | None = None,
rows: list[dict[str, Any]] | None = None,
) -> tuple[AuditStore, list[Any]]:
log: list[Any] = []
store = AuditStore(
tmp_path,
environment=environment or {"GYXX_POSTGRES_DSN": "postgresql://fake/audit"},
connect=lambda: _FakeConnection(log, rows),
)
return store, log
# ----------------------------------------------------------------------
# cookie 快照
# ----------------------------------------------------------------------
def test_summarize_cookies_keeps_only_required_cookies() -> None:
snapshot = summarize_cookies(
DOUYIN_COOKIES,
required_domains=("jinritemai.com",),
required_names=("sessionid", "sid_tt", "session"),
)
assert snapshot.cookie_count == 4
assert snapshot.required_count == 3
# 最早的必需 cookie 过期时间:1766000000,而不是无关 cookie 的 1700000000
assert snapshot.declared_expires_at == datetime.fromtimestamp(
1766000000.0, tz=timezone.utc
)
def test_summarize_cookies_ignores_session_cookies_for_expiry() -> None:
snapshot = summarize_cookies(
DOUYIN_COOKIES,
required_domains=("jinritemai.com",),
required_names=("session",),
)
assert snapshot.required_count == 1
assert snapshot.declared_expires_at is None
def test_summarize_cookies_fingerprint_tracks_value_changes_only() -> None:
baseline = summarize_cookies(DOUYIN_COOKIES, required_domains=("jinritemai.com",))
reordered = summarize_cookies(
list(reversed(DOUYIN_COOKIES)), required_domains=("jinritemai.com",)
)
replaced = [dict(cookie) for cookie in DOUYIN_COOKIES]
replaced[0]["value"] = "ROTATED"
assert baseline.fingerprint # 非空
assert baseline.fingerprint == reordered.fingerprint
assert baseline.fingerprint != summarize_cookies(
replaced, required_domains=("jinritemai.com",)
).fingerprint
def test_summarize_cookies_without_requirements_counts_everything() -> None:
snapshot = summarize_cookies(DOUYIN_COOKIES)
assert snapshot.required_count == 4
assert snapshot.declared_expires_at == datetime.fromtimestamp(
1700000000.0, tz=timezone.utc
)
def test_summarize_cookies_has_no_plaintext_value_in_fingerprint() -> None:
snapshot = summarize_cookies(DOUYIN_COOKIES, required_domains=("jinritemai.com",))
assert "AAA" not in snapshot.fingerprint
assert len(snapshot.fingerprint) == 32
def test_split_step_key_handles_retry_suffix() -> None:
assert split_step_key("sync.attempt-2") == ("sync", 2)
assert split_step_key("collect_collaborators") == ("collect_collaborators", 1)
assert split_step_key("a.attempt-x") == ("a.attempt-x", 1)
# ----------------------------------------------------------------------
# 写入装配
# ----------------------------------------------------------------------
def test_record_run_upserts_run_and_steps(tmp_path: Path) -> None:
store, log = _store_with_fake(tmp_path)
ok = store.record_run(
{
"workflow_id": "content.metrics.daily",
"run_id": "content.metrics.daily__20261018__abc",
"business_date": "2026-10-18",
"status": "success",
"mode": "dry_run",
"shadow": False,
"started_at": "2026-10-19T01:41:43+00:00",
"ended_at": "2026-10-19T01:41:48+00:00",
"error": None,
"journal_path": "runs/content.metrics.daily/run.json",
},
{
"sync.attempt-1": {
"status": "success",
"exit_code": 0,
"started_at": "2026-10-19T01:41:44+00:00",
"ended_at": "2026-10-19T01:41:46+00:00",
},
"collect.attempt-1": {"status": "failed", "exit_code": 4, "error": "boom"},
},
)
assert ok is True
statements = [entry for entry in log if entry[0] == "execute"]
assert any("CREATE TABLE IF NOT EXISTS ops_workflow_runs" in item[1] for item in statements)
insert = next(item for item in statements if "INSERT INTO ops_workflow_runs" in item[1])
params = insert[2]
assert params["business_date"] == date(2026, 10, 18)
assert params["duration_seconds"] == 5
assert params["step_total"] == 2
assert params["step_success"] == 1
assert params["step_failed"] == 1
batch = next(item for item in log if item[0] == "executemany")
rows = {item["step_id"]: item for item in batch[2]}
assert rows["sync"]["attempt"] == 1
assert rows["sync"]["duration_seconds"] == 2
assert rows["collect"]["exit_code"] == 4
assert rows["collect"]["error"] == "boom"
def test_record_run_rejects_incomplete_payload(tmp_path: Path) -> None:
store, log = _store_with_fake(tmp_path)
assert store.record_run({"workflow_id": "x"}) is False
assert not [entry for entry in log if entry[0] == "execute"]
def test_record_cookie_event_writes_value_free_snapshot(tmp_path: Path) -> None:
store, log = _store_with_fake(tmp_path)
snapshot = summarize_cookies(DOUYIN_COOKIES, required_domains=("jinritemai.com",))
ok = store.record_cookie_event(
account_id="douyin-shop",
state="healthy",
snapshot=snapshot,
probe_status="success",
probe_source="keepalive",
workflow_id="content.metrics.daily",
run_id="run-1",
)
assert ok is True
insert = next(
item
for item in log
if item[0] == "execute" and "INSERT INTO ops_account_cookie_events" in item[1]
)
params = insert[2]
assert params["account_id"] == "douyin-shop"
assert params["state"] == "healthy"
assert params["fingerprint"] == snapshot.fingerprint
assert params["declared_expires_at"] == snapshot.declared_expires_at
# 明文值不得出现在任何参数里
assert "AAA" not in json.dumps(params, default=str)
def test_record_cookie_event_rejects_unknown_state(tmp_path: Path) -> None:
store, _ = _store_with_fake(tmp_path)
with pytest.raises(ValueError):
store.record_cookie_event(account_id="a", state="definitely-not-valid")
# ----------------------------------------------------------------------
# 降级与熔断
# ----------------------------------------------------------------------
def test_missing_dsn_never_raises_and_logs_failure(tmp_path: Path) -> None:
store = AuditStore(tmp_path, environment={})
assert store.record_run(
{
"workflow_id": "content.metrics.daily",
"run_id": "r1",
"business_date": "2026-10-18",
"status": "success",
}
) is False
assert "审计数据库配置缺失" in store.last_error
failure_log = tmp_path / "logs" / "ops-audit" / "audit-failures.log"
assert failure_log.exists()
assert json.loads(failure_log.read_text(encoding="utf-8").splitlines()[0])["operation"] == (
"record_run"
)
def test_audit_can_be_disabled_without_touching_the_database(tmp_path: Path) -> None:
store, log = _store_with_fake(
tmp_path, environment={"GYXX_POSTGRES_DSN": "postgresql://fake", "GYXX_OPS_AUDIT": "off"}
)
assert store.enabled is False
assert store.record_cookie_event(account_id="a", state="healthy") is False
assert log == []
def test_consecutive_failures_open_the_circuit(tmp_path: Path) -> None:
attempts = 0
def _boom() -> Any:
nonlocal attempts
attempts += 1
raise OSError("database unreachable")
store = AuditStore(
tmp_path,
environment={"GYXX_POSTGRES_DSN": "postgresql://fake", "GYXX_OPS_AUDIT_FAILURE_COOLDOWN": "60"},
connect=_boom,
)
payload = {
"workflow_id": "w",
"run_id": "r",
"business_date": "2026-10-18",
"status": "success",
}
for _ in range(3):
store.record_run(payload)
assert attempts == 3
# 熔断生效后不再尝试连接,避免每个工作流都卡在 TCP 超时上
assert store.record_run(payload) is False
assert attempts == 3
# ----------------------------------------------------------------------
# 与运行索引的集成
# ----------------------------------------------------------------------
def test_run_index_still_works_when_audit_is_unavailable(tmp_path: Path) -> None:
from gyxx_flow.core.context import RunContext
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.core.records import RunJournal
layout = DataLayout(tmp_path)
context = RunContext.create("content.metrics.daily", "2026-10-18")
journal = RunJournal.create(layout, context, mode="dry_run")
journal.finalize("success")
record = RunIndex(tmp_path).index_journal(journal)
assert record.workflow_id == "content.metrics.daily"
assert record.status == "success"
# 运行索引文件仍然写出,审计失败不影响主链路
assert (tmp_path / "state" / "ops" / "run-index" / f"{record.run_id}.json").exists()
def test_schema_sql_defines_both_audit_surfaces() -> None:
assert "ops_workflow_runs" in SCHEMA_SQL
assert "ops_workflow_run_steps" in SCHEMA_SQL
assert "ops_account_cookie_events" in SCHEMA_SQL
assert "ops_account_cookie_lifetimes" in SCHEMA_SQL
assert "ops_account_cookie_stats" in SCHEMA_SQL
-139
View File
@@ -1,139 +0,0 @@
# 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(默认 <http://127.0.0.1:3080>):
- 侧边栏底部出现「工作流」按钮 → 打开工作流面板;
- 面板按模块分组列出全部调度工作流(状态点:绿=成功 / 红=失败 / 蓝=运行中 / 灰=未运行);
- **选中任意工作流**后可直接:
- 「提问」:带着该工作流上下文创建智能体会话,自由提问;
- 「诊断」:自动获取最近一次失败运行的诊断包(步骤、脱敏日志)并输出根因报告;
- 「修复」:智能体先诊断再给修复方案,**任何正式执行/调度修改必须先经你确认**;
- 「试运行 / 正式运行 / 停止」:对应控制台的受控执行语义(试运行无外部副作用)。
降级方案:若 dsh 客户端包因版本差异未能加载,直接打开桥接服务自带的独立面板
<http://127.0.0.1:8790/>,功能与侧边栏面板一致(零依赖页面)。
## 目录结构
```
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。
-77
View File
@@ -1,77 +0,0 @@
# 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'
$LocalYml = Join-Path $WorkbenchDir 'cordis.local.yml'
# 1) 渲染 cordis.local.yml
$template = Get-Content (Join-Path $WorkbenchDir 'cordis.template.yml') -Raw -Encoding UTF8
$workbenchDirPosix = ($WorkbenchDir -replace '\\', '/')
$projectRootPosix = ($RepoRoot -replace '\\', '/')
# Windows 上 dsh 的 ESM 加载器要求 file:// URL
$workbenchModuleUrl = 'file:///' + $workbenchDirPosix
$rendered = $template.Replace('__WORKBENCH_DIR__', $workbenchModuleUrl).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
}
-43
View File
@@ -1,43 +0,0 @@
#!/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(插件路径必须为绝对路径)
# dsh 的 ESM 加载器要求 file:// URL(Windows 上必须,POSIX 上同样可用)
sed \
-e "s|__WORKBENCH_DIR__|file://$WORKBENCH_DIR|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
-22
View File
@@ -1,22 +0,0 @@
# GYXX 智能工作台 —— dsh Web 组合补丁(模板)。
# 启动脚本会把 __WORKBENCH_DIR__ / __PROJECT_ROOT__ 渲染为绝对路径后生成
# cordis.local.yml,再通过 `dsh web --patch workbench/cordis.local.yml` 加载。
# 不要直接手工使用本文件:dsh 要求插件路径为绝对路径(Windows 上必须是 file:// URL)。
- insert:
- id: gyxx-workbench
name: '__WORKBENCH_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__'
-355
View File
@@ -1,355 +0,0 @@
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
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 (
<div className="gyxxwb-pills">
<span className="gyxxwb-pill">共 {summary?.total ?? workflows.length}</span>
{running > 0 && <span className="gyxxwb-pill gyxxwb-pill-running">运行中 {running}</span>}
{failed > 0 && <span className="gyxxwb-pill gyxxwb-pill-failed">失败 {failed}</span>}
</div>
)
}
function WorkflowRow({ workflow, selected, onSelect }) {
const status = statusOf(workflow)
return (
<button
type="button"
className={`gyxxwb-row ${selected ? 'gyxxwb-row-selected' : ''}`}
onClick={() => onSelect(workflow.id)}
>
<span className={`gyxxwb-dot gyxxwb-dot-${status}`} />
<span className="gyxxwb-row-main">
<span className="gyxxwb-row-name">{workflow.name}</span>
<span className="gyxxwb-row-id">{workflow.id}</span>
</span>
<span className="gyxxwb-row-meta">
{workflow.last_run ? formatTime(workflow.last_run.ended_at ?? workflow.last_run.started_at) : '—'}
</span>
</button>
)
}
function RunList({ runs, onDiagnose }) {
if (!runs.length) return <div className="gyxxwb-empty">暂无运行记录</div>
return (
<div className="gyxxwb-runs">
{runs.map((run) => (
<button
type="button"
key={run.run_id}
className="gyxxwb-run"
onClick={() => onDiagnose(run.run_id)}
title="点击查看诊断详情"
>
<span className={`gyxxwb-dot gyxxwb-dot-${run.status}`} />
<span>{run.business_date}</span>
<span className="gyxxwb-run-mode">{run.mode === 'execute' ? '正式' : '试运行'}</span>
<span>{formatDuration(run.duration_seconds)}</span>
{run.error && <span className="gyxxwb-run-error">{run.error.slice(0, 60)}</span>}
</button>
))}
</div>
)
}
function DiagnosisView({ diagnosis, loading }) {
if (loading) return <div className="gyxxwb-empty">正在加载诊断数据…</div>
if (!diagnosis) return null
if (diagnosis.message) return <div className="gyxxwb-empty">{diagnosis.message}</div>
const run = diagnosis.run ?? {}
const steps = Array.isArray(run.steps) ? run.steps : []
const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []
return (
<div className="gyxxwb-diagnosis">
<div className="gyxxwb-diagnosis-head">
诊断:{run.run_id}({run.status})
</div>
{steps
.filter((step) => step.status === 'failed')
.map((step) => (
<div key={step.id} className="gyxxwb-step-fail">
<div className="gyxxwb-step-id">
✗ {step.id}(退出码 {step.exit_code ?? '—'})
</div>
{step.error && <pre className="gyxxwb-pre">{step.error}</pre>}
</div>
))}
{logs.map((log) => (
<div key={log.path} className="gyxxwb-log">
<div className="gyxxwb-log-path">{log.path}</div>
<pre className="gyxxwb-pre">{log.tail}</pre>
</div>
))}
{!steps.some((s) => s.status === 'failed') && !logs.length && (
<div className="gyxxwb-empty">该运行没有失败步骤或日志</div>
)}
</div>
)
}
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 (
<div className={`gyxxwb-panel ${s.open ? 'gyxxwb-panel-open' : ''}`}>
<div className="gyxxwb-panel-head">
<span className="gyxxwb-title">GYXX 工作流</span>
<SummaryPills summary={s.summary} workflows={s.workflows} />
<button type="button" className="gyxxwb-icon-btn" title="刷新" onClick={refreshWorkflows}>
⟳
</button>
<button
type="button"
className="gyxxwb-icon-btn"
title="关闭"
onClick={() => update({ open: false })}
>
✕
</button>
</div>
{s.error && (
<div className="gyxxwb-error">
无法连接工作台桥接服务:{s.error}
<br />
请确认 dsh 已通过 workbench/cordis.yml 启动。
</div>
)}
<div className="gyxxwb-panel-body">
<div className="gyxxwb-list">
{groups.map((group) => (
<div key={group.label} className="gyxxwb-group">
<div className="gyxxwb-group-label">{group.label}</div>
{group.items.map((workflow) => (
<WorkflowRow
key={workflow.id}
workflow={workflow}
selected={workflow.id === s.selectedId}
onSelect={onSelect}
/>
))}
</div>
))}
{!s.loading && !s.workflows.length && !s.error && (
<div className="gyxxwb-empty">未发现工作流</div>
)}
</div>
{selected && (
<div className="gyxxwb-detail">
<div className="gyxxwb-detail-title">
{selected.name}
<span className={`gyxxwb-status gyxxwb-status-${statusOf(selected)}`}>
{STATUS_LABELS[statusOf(selected)]}
</span>
</div>
<div className="gyxxwb-detail-sub">
{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)}` : ''}
</div>
<div className="gyxxwb-actions">
<button type="button" onClick={() => doAsk('diagnose')}>诊断</button>
<button type="button" onClick={() => doAsk('repair')}>修复</button>
<button type="button" onClick={() => doTrigger(false)}>试运行</button>
<button type="button" className="gyxxwb-danger" onClick={() => doTrigger(true)}>
正式运行
</button>
{selected.active_run && (
<button type="button" className="gyxxwb-danger" onClick={doCancel}>
停止
</button>
)}
</div>
<div className="gyxxwb-ask">
<input
value={s.question}
placeholder="就这个工作流提问,例如:昨天为什么失败?"
onChange={(event) => update({ question: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') doAsk('ask')
}}
/>
<button type="button" onClick={() => doAsk('ask')}>提问</button>
</div>
<div className="gyxxwb-date-row">
业务日期{' '}
<input
value={s.businessDate}
onChange={(event) => update({ businessDate: event.target.value })}
pattern="\d{4}-\d{2}-\d{2}"
/>
</div>
<div className="gyxxwb-section-label">最近运行</div>
<RunList runs={s.runs} onDiagnose={(runId) => loadDiagnosis(selected.id, runId)} />
<DiagnosisView diagnosis={s.diagnosis} loading={s.diagnosisLoading} />
</div>
)}
</div>
</div>
)
}
-74
View File
@@ -1,74 +0,0 @@
/** 失败告警浮层:轮询桥接告警,新失败弹出 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 (
<div className="gyxxwb-toasts">
{s.toasts.map((toast) => (
<div key={toast.id} className={`gyxxwb-toast gyxxwb-toast-${toast.kind}`}>
<span className="gyxxwb-toast-text">{toast.text}</span>
{toast.alert && (
<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: '已创建诊断会话,请在左侧会话列表查看' })
} catch (error) {
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
}
dismissToast(toast.id)
}}
>
立即诊断
</button>
)}
<button type="button" title="关闭" onClick={() => dismissToast(toast.id)}>
✕
</button>
</div>
))}
</div>
)
}
-55
View File
@@ -1,55 +0,0 @@
/** 工作台桥接服务客户端:所有数据经由 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),
}
-85
View File
@@ -1,85 +0,0 @@
/**
* 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()
// 插槽须等宿主声明后才能注册:slots.inject 会等待座位声明(顺序安全),
// 直接 register 在我们先于 ui-sidebar/ui-layout 加载时会报“slot is not declared”。
ctx.effect(
() =>
ctx.slots.inject('sidebar.footer.action', () =>
ctx.slots.register(
{ name: 'sidebar.footer.action', id: 'gyxx-workbench.footer' },
WorkbenchFooterAction,
),
),
'gyxx-workbench: footer action',
)
ctx.effect(
() =>
ctx.slots.inject('shell.overlay', () =>
ctx.slots.register(
{ name: 'shell.overlay', id: 'gyxx-workbench.overlay' },
WorkbenchOverlay,
),
),
'gyxx-workbench: overlay',
)
}
-57
View File
@@ -1,57 +0,0 @@
/**
* 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) })
}
-471
View File
@@ -1,471 +0,0 @@
/* GYXX 工作台面板样式 —— 以文本形式内联进客户端包,运行时注入 <style>。 */
.gyxxwb-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 70;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
.gyxxwb-panel {
position: absolute;
top: 0;
bottom: 0;
left: 264px;
width: 380px;
display: none;
flex-direction: column;
background: #ffffff;
border-right: 1px solid #e4e4e7;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
pointer-events: auto;
color: #18181b;
}
.gyxxwb-panel-open {
display: flex;
}
.gyxxwb-panel-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e4e4e7;
}
.gyxxwb-title {
font-weight: 600;
font-size: 14px;
}
.gyxxwb-pills {
display: flex;
gap: 4px;
flex: 1;
}
.gyxxwb-pill {
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
background: #f4f4f5;
color: #52525b;
}
.gyxxwb-pill-failed {
background: #fef2f2;
color: #b91c1c;
}
.gyxxwb-pill-running {
background: #eff6ff;
color: #1d4ed8;
}
.gyxxwb-icon-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #71717a;
padding: 4px 6px;
border-radius: 6px;
}
.gyxxwb-icon-btn:hover {
background: #f4f4f5;
}
.gyxxwb-error {
margin: 10px 12px;
padding: 10px;
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
line-height: 1.6;
}
.gyxxwb-panel-body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.gyxxwb-group-label {
padding: 10px 12px 4px;
font-size: 12px;
color: #71717a;
}
.gyxxwb-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.gyxxwb-row:hover {
background: #f4f4f5;
}
.gyxxwb-row-selected {
background: #eef2ff;
}
.gyxxwb-row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.gyxxwb-row-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gyxxwb-row-id {
font-size: 11px;
color: #a1a1aa;
}
.gyxxwb-row-meta {
font-size: 11px;
color: #71717a;
}
.gyxxwb-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d4d4d8;
flex: none;
}
.gyxxwb-dot-success {
background: #16a34a;
}
.gyxxwb-dot-failed {
background: #dc2626;
}
.gyxxwb-dot-running {
background: #2563eb;
animation: gyxxwb-pulse 1.2s ease-in-out infinite;
}
.gyxxwb-dot-cancelled,
.gyxxwb-dot-none {
background: #a1a1aa;
}
@keyframes gyxxwb-pulse {
50% {
opacity: 0.35;
}
}
.gyxxwb-detail {
border-top: 1px solid #e4e4e7;
padding: 12px;
background: #fafafa;
}
.gyxxwb-detail-title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.gyxxwb-status {
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
background: #f4f4f5;
}
.gyxxwb-status-failed {
background: #fef2f2;
color: #b91c1c;
}
.gyxxwb-status-running {
background: #eff6ff;
color: #1d4ed8;
}
.gyxxwb-status-success {
background: #f0fdf4;
color: #15803d;
}
.gyxxwb-detail-sub {
font-size: 11px;
color: #71717a;
margin: 4px 0 10px;
}
.gyxxwb-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gyxxwb-actions button,
.gyxxwb-ask button {
border: 1px solid #e4e4e7;
background: #ffffff;
border-radius: 6px;
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
}
.gyxxwb-actions button:hover,
.gyxxwb-ask button:hover {
background: #f4f4f5;
}
.gyxxwb-actions .gyxxwb-danger {
border-color: #fecaca;
color: #b91c1c;
}
.gyxxwb-ask {
display: flex;
gap: 6px;
margin-top: 8px;
}
.gyxxwb-ask input {
flex: 1;
border: 1px solid #e4e4e7;
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
}
.gyxxwb-date-row {
margin-top: 8px;
font-size: 11px;
color: #71717a;
display: flex;
align-items: center;
gap: 6px;
}
.gyxxwb-date-row input {
border: 1px solid #e4e4e7;
border-radius: 6px;
padding: 3px 6px;
font-size: 11px;
width: 110px;
}
.gyxxwb-section-label {
margin: 12px 0 4px;
font-size: 12px;
color: #71717a;
}
.gyxxwb-run {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
text-align: left;
border-radius: 6px;
}
.gyxxwb-run:hover {
background: #f4f4f5;
}
.gyxxwb-run-mode {
color: #a1a1aa;
font-size: 11px;
}
.gyxxwb-run-error {
color: #b91c1c;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gyxxwb-diagnosis {
margin-top: 10px;
}
.gyxxwb-diagnosis-head {
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
}
.gyxxwb-step-fail {
margin-bottom: 8px;
}
.gyxxwb-step-id {
font-size: 12px;
color: #b91c1c;
margin-bottom: 2px;
}
.gyxxwb-log {
margin-bottom: 8px;
}
.gyxxwb-log-path {
font-size: 11px;
color: #71717a;
margin-bottom: 2px;
word-break: break-all;
}
.gyxxwb-pre {
font-size: 11px;
background: #18181b;
color: #e4e4e7;
border-radius: 6px;
padding: 8px;
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.gyxxwb-empty {
padding: 16px 12px;
font-size: 12px;
color: #a1a1aa;
text-align: center;
}
.gyxxwb-toasts {
position: absolute;
right: 16px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
max-width: 360px;
}
.gyxxwb-toast {
display: flex;
align-items: center;
gap: 8px;
background: #18181b;
color: #fafafa;
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.gyxxwb-toast button {
border: 1px solid #3f3f46;
background: transparent;
color: #fafafa;
border-radius: 6px;
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
}
.gyxxwb-toast-ok {
background: #14532d;
}
.gyxxwb-toast-error,
.gyxxwb-toast-failed {
background: #7f1d1d;
}
.gyxxwb-footer-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: inherit;
padding: 6px 8px;
border-radius: 8px;
}
.gyxxwb-footer-btn:hover,
.gyxxwb-footer-btn-active {
background: rgba(0, 0, 0, 0.06);
}
@media (max-width: 900px) {
.gyxxwb-panel {
left: 0;
width: 100%;
}
}
@media (prefers-color-scheme: dark) {
.gyxxwb-panel {
background: #18181b;
color: #fafafa;
border-right-color: #3f3f46;
}
.gyxxwb-row:hover {
background: #27272a;
}
.gyxxwb-row-selected {
background: #312e81;
}
.gyxxwb-detail {
background: #1f1f22;
border-top-color: #3f3f46;
}
.gyxxwb-actions button,
.gyxxwb-ask button {
background: #27272a;
border-color: #3f3f46;
color: #fafafa;
}
.gyxxwb-ask input,
.gyxxwb-date-row input {
background: #27272a;
border-color: #3f3f46;
color: #fafafa;
}
.gyxxwb-pill {
background: #27272a;
color: #a1a1aa;
}
.gyxxwb-footer-btn:hover,
.gyxxwb-footer-btn-active {
background: rgba(255, 255, 255, 0.08);
}
}
-258
View File
@@ -1,258 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GYXX 智能工作台</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #f4f4f5; color: #18181b;
}
header {
display: flex; align-items: center; gap: 10px; padding: 12px 16px;
background: #fff; border-bottom: 1px solid #e4e4e7; position: sticky; top: 0; z-index: 2;
}
h1 { font-size: 15px; margin: 0; }
.pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: #f4f4f5; color: #52525b; border: 1px solid #e4e4e7; }
.pill-failed { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.pill-running { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
main { display: flex; gap: 0; min-height: calc(100vh - 49px); }
#list { width: 320px; background: #fff; border-right: 1px solid #e4e4e7; overflow-y: auto; }
#detail { flex: 1; padding: 16px; overflow-y: auto; }
.group-label { padding: 10px 12px 4px; font-size: 12px; color: #71717a; }
.row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; border: none; background: none; cursor: pointer; text-align: left; }
.row:hover { background: #f4f4f5; }
.row.selected { background: #eef2ff; }
.row .name { font-size: 13px; display: block; }
.row .id { font-size: 11px; color: #a1a1aa; display: block; }
.row .meta { font-size: 11px; color: #71717a; margin-left: auto; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #a1a1aa; flex: none; }
.dot-success { background: #16a34a; } .dot-failed { background: #dc2626; }
.dot-running { background: #2563eb; animation: pulse 1.2s infinite; }
@keyframes pulse { 50% { opacity: .35; } }
button.act { border: 1px solid #e4e4e7; background: #fff; border-radius: 6px; font-size: 12px; padding: 5px 12px; cursor: pointer; margin-right: 6px; }
button.act:hover { background: #f4f4f5; }
button.danger { border-color: #fecaca; color: #b91c1c; }
input { border: 1px solid #e4e4e7; border-radius: 6px; padding: 6px 8px; font-size: 12px; }
pre { background: #18181b; color: #e4e4e7; border-radius: 8px; padding: 10px; font-size: 11px; max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all; }
.muted { color: #71717a; font-size: 12px; }
.err { color: #b91c1c; font-size: 12px; }
.card { background: #fff; border: 1px solid #e4e4e7; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 9; }
.toast { background: #18181b; color: #fafafa; border-radius: 10px; padding: 10px 12px; font-size: 12px; max-width: 340px; }
.toast.ok { background: #14532d; } .toast.err { background: #7f1d1d; }
@media (prefers-color-scheme: dark) {
body { background: #09090b; color: #fafafa; }
header, #list, .card { background: #18181b; border-color: #3f3f46; }
.row:hover { background: #27272a; } .row.selected { background: #312e81; }
button.act, input { background: #27272a; border-color: #3f3f46; color: #fafafa; }
}
</style>
</head>
<body>
<header>
<h1>GYXX 智能工作台</h1>
<span id="pills"></span>
<span class="muted" style="margin-left:auto" id="generated"></span>
<button class="act" onclick="loadWorkflows()">刷新</button>
</header>
<main>
<div id="list"></div>
<div id="detail"><p class="muted" style="padding:16px">在左侧选择一个工作流,可提问、诊断、修复或启动。</p></div>
</main>
<div id="toasts"></div>
<script>
const $ = (sel) => document.querySelector(sel)
const state = { workflows: [], selected: null, detail: null, runs: [], seenAlerts: {} }
function toast(text, kind) {
const el = document.createElement('div')
el.className = 'toast ' + (kind || '')
el.textContent = text
$('#toasts').appendChild(el)
setTimeout(() => el.remove(), 8000)
}
async function req(method, path, body) {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' } : {},
body: body === undefined ? undefined : JSON.stringify(body),
})
const payload = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status))
return payload
}
function statusOf(w) { return w.active_run ? 'running' : (w.last_run && w.last_run.status) || 'none' }
function fmtTime(v) {
if (!v) return '—'
const d = new Date(v); if (isNaN(d)) return v
const p = (n) => String(n).padStart(2, '0')
return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])) }
async function loadWorkflows() {
try {
const payload = await req('GET', '/bridge/workflows')
state.workflows = payload.workflows || []
$('#generated').textContent = payload.generated_at ? ('更新于 ' + fmtTime(payload.generated_at)) : ''
renderList()
} catch (e) {
$('#list').innerHTML = '<p class="err" style="padding:12px">无法连接桥接服务:' + esc(e.message) + '<br>请确认 gyxx console 与 dsh workbench 插件已启动。</p>'
}
}
function renderList() {
const failed = state.workflows.filter((w) => statusOf(w) === 'failed').length
const running = state.workflows.filter((w) => w.active_run).length
$('#pills').innerHTML =
`<span class="pill">共 ${state.workflows.length}</span>` +
(running ? `<span class="pill pill-running">运行中 ${running}</span>` : '') +
(failed ? `<span class="pill pill-failed">失败 ${failed}</span>` : '')
const byModule = new Map()
for (const w of state.workflows) {
const key = w.module_label || w.module
if (!byModule.has(key)) byModule.set(key, [])
byModule.get(key).push(w)
}
let html = ''
for (const [label, items] of byModule) {
html += `<div class="group-label">${esc(label)}</div>`
for (const w of items) {
const st = statusOf(w)
html += `<button class="row ${state.selected === w.id ? 'selected' : ''}" onclick="select('${esc(w.id)}')">
<span class="dot dot-${st}"></span>
<span><span class="name">${esc(w.name)}</span><span class="id">${esc(w.id)}</span></span>
<span class="meta">${w.last_run ? fmtTime(w.last_run.ended_at || w.last_run.started_at) : '—'}</span>
</button>`
}
}
$('#list').innerHTML = html || '<p class="muted" style="padding:12px">未发现工作流</p>'
}
async function select(id) {
state.selected = id
renderList()
$('#detail').innerHTML = '<p class="muted" style="padding:16px">加载中…</p>'
try {
const [detail, runs] = await Promise.all([
req('GET', '/bridge/workflows/' + encodeURIComponent(id)).catch(() => null),
req('GET', '/bridge/workflows/' + encodeURIComponent(id) + '/runs?limit=6').catch(() => ({ runs: [] })),
])
state.detail = detail
state.runs = runs.runs || []
renderDetail()
} catch (e) {
$('#detail').innerHTML = '<p class="err" style="padding:16px">' + esc(e.message) + '</p>'
}
}
function renderDetail() {
const w = state.workflows.find((x) => x.id === state.selected)
if (!w) return
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1)
const p = (n) => String(n).padStart(2, '0')
const defaultDate = `${yesterday.getFullYear()}-${p(yesterday.getMonth()+1)}-${p(yesterday.getDate())}`
const sched = w.schedule
let html = `<div class="card">
<h2 style="font-size:15px;margin:0 0 4px">${esc(w.name)}</h2>
<div class="muted">${esc(w.id)} · 调度 ${sched ? (sched.enabled ? '启用' : '停用') + ' ' + (Array.isArray(sched.at) ? sched.at.join('/') : sched.at || '') : '无'}</div>
<div style="margin:10px 0">
<button class="act" onclick="ask('diagnose')">诊断</button>
<button class="act" onclick="ask('repair')">修复</button>
<button class="act" onclick="triggerRun(false)">试运行</button>
<button class="act danger" onclick="triggerRun(true)">正式运行</button>
${w.active_run ? '<button class="act danger" onclick="cancelRun()">停止</button>' : ''}
</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<input id="q" style="flex:1" placeholder="就这个工作流提问,例如:昨天为什么失败?" />
<button class="act" onclick="ask('ask')">提问</button>
</div>
<div class="muted">业务日期 <input id="bd" value="${defaultDate}" style="width:110px" /></div>
</div>
<div class="card"><div class="muted" style="margin-bottom:6px">最近运行(点击查看诊断)</div>`
if (!state.runs.length) html += '<div class="muted">暂无运行记录</div>'
for (const r of state.runs) {
html += `<button class="row" onclick="diagnose('${esc(r.run_id)}')">
<span class="dot dot-${r.status}"></span><span>${esc(r.business_date)}</span>
<span class="muted">${r.mode === 'execute' ? '正式' : '试运行'}</span>
${r.error ? `<span class="err">${esc(r.error.slice(0, 80))}</span>` : ''}
</button>`
}
html += '</div><div id="diag"></div>'
$('#detail').innerHTML = html
}
async function diagnose(runId) {
$('#diag').innerHTML = '<div class="card muted">加载诊断数据…</div>'
try {
const d = await req('GET', '/bridge/workflows/' + encodeURIComponent(state.selected) + '/diagnosis?run_id=' + encodeURIComponent(runId))
let html = '<div class="card"><div class="muted">诊断:' + esc(runId) + '</div>'
const steps = (d.run && d.run.steps) || []
for (const s of steps.filter((x) => x.status === 'failed')) {
html += `<div class="err">✗ ${esc(s.id)}(退出码 ${s.exit_code == null ? '—' : s.exit_code})</div>${s.error ? '<pre>' + esc(s.error) + '</pre>' : ''}`
}
for (const log of d.logs || []) {
html += `<div class="muted" style="margin-top:8px">${esc(log.path)}</div><pre>${esc(log.tail)}</pre>`
}
if (!steps.some((x) => x.status === 'failed') && !(d.logs || []).length) html += '<div class="muted">该运行没有失败步骤或日志</div>'
$('#diag').innerHTML = html + '</div>'
} catch (e) {
$('#diag').innerHTML = '<div class="card err">' + esc(e.message) + '</div>'
}
}
async function ask(action) {
const question = ($('#q') && $('#q').value) || ''
try {
const payload = await req('POST', '/bridge/ask', { workflow_id: state.selected, action, question })
toast('已创建会话(' + String(payload.session_id).slice(0, 24) + '…),请在 dsh 侧边栏会话列表中查看', 'ok')
} catch (e) { toast('创建会话失败:' + e.message, 'err') }
}
async function triggerRun(execute) {
const businessDate = ($('#bd') && $('#bd').value) || ''
if (execute && !confirm(`正式执行 ${state.selected}(业务日期 ${businessDate})会产生真实外部写入。确认继续?`)) return
try {
await req('POST', '/bridge/trigger', { workflow_id: state.selected, business_date: businessDate, execute, confirmed: execute })
toast(execute ? '已发起正式执行' : '已发起试运行(无外部副作用)', 'ok')
setTimeout(loadWorkflows, 1500)
} catch (e) { toast('启动失败:' + e.message, 'err') }
}
async function cancelRun() {
const w = state.workflows.find((x) => x.id === state.selected)
const body = w && w.active_run && w.active_run.operation_id
? { workflow_id: state.selected, operation_id: w.active_run.operation_id }
: { workflow_id: state.selected, scheduled: true }
try {
await req('POST', '/bridge/cancel', body)
toast('已发送停止指令', 'ok')
setTimeout(loadWorkflows, 1500)
} catch (e) { toast('停止失败:' + e.message, 'err') }
}
async function pollAlerts() {
try {
const payload = await req('GET', '/bridge/alerts')
for (const alert of payload.alerts || []) {
if (alert.seen || state.seenAlerts[alert.id]) continue
state.seenAlerts[alert.id] = true
toast('工作流「' + alert.name + '」运行失败,可在 dsh 中发起诊断', 'err')
req('POST', '/bridge/alerts/seen', { id: alert.id }).catch(() => {})
}
} catch (e) { /* 桥接离线时静默 */ }
}
loadWorkflows()
setInterval(loadWorkflows, 15000)
setInterval(pollAlerts, 30000)
</script>
</body>
</html>
-937
View File
@@ -1,937 +0,0 @@
/**
* 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('<h1>gyxx-workbench bridge</h1>', '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')
}
-580
View File
@@ -1,580 +0,0 @@
window.__ModuleLoader__.load({ id: "@gyxx/dsh-plugin-gyxx-workbench", factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
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 <style>\u3002 */\n\n.gyxxwb-overlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 70;\n font-family:\n -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',\n 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;\n}\n\n.gyxxwb-panel {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 264px;\n width: 380px;\n display: none;\n flex-direction: column;\n background: #ffffff;\n border-right: 1px solid #e4e4e7;\n box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);\n pointer-events: auto;\n color: #18181b;\n}\n\n.gyxxwb-panel-open {\n display: flex;\n}\n\n.gyxxwb-panel-head {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n border-bottom: 1px solid #e4e4e7;\n}\n\n.gyxxwb-title {\n font-weight: 600;\n font-size: 14px;\n}\n\n.gyxxwb-pills {\n display: flex;\n gap: 4px;\n flex: 1;\n}\n\n.gyxxwb-pill {\n font-size: 11px;\n padding: 1px 8px;\n border-radius: 999px;\n background: #f4f4f5;\n color: #52525b;\n}\n\n.gyxxwb-pill-failed {\n background: #fef2f2;\n color: #b91c1c;\n}\n\n.gyxxwb-pill-running {\n background: #eff6ff;\n color: #1d4ed8;\n}\n\n.gyxxwb-icon-btn {\n border: none;\n background: transparent;\n cursor: pointer;\n font-size: 14px;\n color: #71717a;\n padding: 4px 6px;\n border-radius: 6px;\n}\n\n.gyxxwb-icon-btn:hover {\n background: #f4f4f5;\n}\n\n.gyxxwb-error {\n margin: 10px 12px;\n padding: 10px;\n border-radius: 8px;\n background: #fef2f2;\n color: #b91c1c;\n font-size: 12px;\n line-height: 1.6;\n}\n\n.gyxxwb-panel-body {\n flex: 1;\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n}\n\n.gyxxwb-group-label {\n padding: 10px 12px 4px;\n font-size: 12px;\n color: #71717a;\n}\n\n.gyxxwb-row {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 7px 12px;\n border: none;\n background: transparent;\n cursor: pointer;\n text-align: left;\n}\n\n.gyxxwb-row:hover {\n background: #f4f4f5;\n}\n\n.gyxxwb-row-selected {\n background: #eef2ff;\n}\n\n.gyxxwb-row-main {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n}\n\n.gyxxwb-row-name {\n font-size: 13px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.gyxxwb-row-id {\n font-size: 11px;\n color: #a1a1aa;\n}\n\n.gyxxwb-row-meta {\n font-size: 11px;\n color: #71717a;\n}\n\n.gyxxwb-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #d4d4d8;\n flex: none;\n}\n\n.gyxxwb-dot-success {\n background: #16a34a;\n}\n\n.gyxxwb-dot-failed {\n background: #dc2626;\n}\n\n.gyxxwb-dot-running {\n background: #2563eb;\n animation: gyxxwb-pulse 1.2s ease-in-out infinite;\n}\n\n.gyxxwb-dot-cancelled,\n.gyxxwb-dot-none {\n background: #a1a1aa;\n}\n\n@keyframes gyxxwb-pulse {\n 50% {\n opacity: 0.35;\n }\n}\n\n.gyxxwb-detail {\n border-top: 1px solid #e4e4e7;\n padding: 12px;\n background: #fafafa;\n}\n\n.gyxxwb-detail-title {\n font-size: 14px;\n font-weight: 600;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.gyxxwb-status {\n font-size: 11px;\n padding: 1px 8px;\n border-radius: 999px;\n background: #f4f4f5;\n}\n\n.gyxxwb-status-failed {\n background: #fef2f2;\n color: #b91c1c;\n}\n\n.gyxxwb-status-running {\n background: #eff6ff;\n color: #1d4ed8;\n}\n\n.gyxxwb-status-success {\n background: #f0fdf4;\n color: #15803d;\n}\n\n.gyxxwb-detail-sub {\n font-size: 11px;\n color: #71717a;\n margin: 4px 0 10px;\n}\n\n.gyxxwb-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n}\n\n.gyxxwb-actions button,\n.gyxxwb-ask button {\n border: 1px solid #e4e4e7;\n background: #ffffff;\n border-radius: 6px;\n font-size: 12px;\n padding: 4px 10px;\n cursor: pointer;\n}\n\n.gyxxwb-actions button:hover,\n.gyxxwb-ask button:hover {\n background: #f4f4f5;\n}\n\n.gyxxwb-actions .gyxxwb-danger {\n border-color: #fecaca;\n color: #b91c1c;\n}\n\n.gyxxwb-ask {\n display: flex;\n gap: 6px;\n margin-top: 8px;\n}\n\n.gyxxwb-ask input {\n flex: 1;\n border: 1px solid #e4e4e7;\n border-radius: 6px;\n padding: 5px 8px;\n font-size: 12px;\n}\n\n.gyxxwb-date-row {\n margin-top: 8px;\n font-size: 11px;\n color: #71717a;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.gyxxwb-date-row input {\n border: 1px solid #e4e4e7;\n border-radius: 6px;\n padding: 3px 6px;\n font-size: 11px;\n width: 110px;\n}\n\n.gyxxwb-section-label {\n margin: 12px 0 4px;\n font-size: 12px;\n color: #71717a;\n}\n\n.gyxxwb-run {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n padding: 6px 8px;\n border: none;\n background: transparent;\n cursor: pointer;\n font-size: 12px;\n text-align: left;\n border-radius: 6px;\n}\n\n.gyxxwb-run:hover {\n background: #f4f4f5;\n}\n\n.gyxxwLine truncated
// client/src/index.jsx
var inject = ["slots"];
function useWorkbench3() {
return (0, import_react4.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 = styles_default;
document.head.appendChild(style);
}
function WorkbenchFooterAction() {
const s = useWorkbench3();
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 (0, import_react4.createElement)(
"button",
{
type: "button",
className: `gyxxwb-footer-btn${s.open ? " gyxxwb-footer-btn-active" : ""}`,
title: "GYXX \u5DE5\u4F5C\u6D41\u5DE5\u4F5C\u53F0",
onClick: () => {
const next = !getState().open;
update({ open: next });
if (next) refreshWorkflows();
}
},
(0, import_react4.createElement)("span", { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),
(0, import_react4.createElement)("span", null, "\u5DE5\u4F5C\u6D41")
);
}
function WorkbenchOverlay() {
return (0, import_react4.createElement)(
"div",
{ className: "gyxxwb-overlay" },
(0, import_react4.createElement)(WorkflowPanel, null),
(0, import_react4.createElement)(Toasts, null)
);
}
function apply(ctx) {
injectStyles();
ctx.effect(
() => ctx.slots.inject(
"sidebar.footer.action",
() => ctx.slots.register(
{ name: "sidebar.footer.action", id: "gyxx-workbench.footer" },
WorkbenchFooterAction
)
),
"gyxx-workbench: footer action"
);
ctx.effect(
() => ctx.slots.inject(
"shell.overlay",
() => ctx.slots.register(
{ name: "shell.overlay", id: "gyxx-workbench.overlay" },
WorkbenchOverlay
)
),
"gyxx-workbench: overlay"
);
}
return module.exports; } });
//# sourceMappingURL=client.js.map
-7
View File
@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../client/src/index.jsx", "../client/src/Panel.jsx", "../client/src/store.js", "../client/src/api.js", "../client/src/Toasts.jsx", "../client/src/styles.css"],
"sourcesContent": ["/**\n * GYXX \u5DE5\u4F5C\u53F0\u5BA2\u6237\u7AEF\u63D2\u4EF6\u5165\u53E3\u3002\n * \u7531 esbuild \u6253\u5305\u4E3A CJS \u5E76\u901A\u8FC7 window.__ModuleLoader__ \u6CE8\u518C\uFF08\u89C1 scripts/build-client.mjs\uFF09\u3002\n * \u8FD0\u884C\u65F6\u53EA\u4F9D\u8D56 dsh \u5E73\u53F0\u5171\u4EAB\u6A21\u5757\u8868\u4E2D\u7684 react\u3002\n */\n\nimport { createElement, useSyncExternalStore } from 'react'\nimport { WorkflowPanel, refreshWorkflows } from './Panel.jsx'\nimport { Toasts } from './Toasts.jsx'\nimport { getState, subscribe, update } from './store.js'\nimport stylesText from './styles.css'\n\nexport const inject = ['slots']\n\nfunction useWorkbench() {\n return useSyncExternalStore(subscribe, getState)\n}\n\nfunction injectStyles() {\n if (typeof document === 'undefined') return\n if (document.getElementById('gyxxwb-styles')) return\n const style = document.createElement('style')\n style.id = 'gyxxwb-styles'\n style.textContent = stylesText\n document.head.appendChild(style)\n}\n\n/** \u4FA7\u8FB9\u680F\u5E95\u90E8\u52A8\u4F5C\uFF1A\u5DE5\u4F5C\u6D41\u9762\u677F\u5F00\u5173 + \u805A\u5408\u72B6\u6001\u70B9\u3002 */\nfunction WorkbenchFooterAction() {\n const s = useWorkbench()\n const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length\n const running = s.workflows.filter((w) => w.active_run).length\n const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success'\n return createElement(\n 'button',\n {\n type: 'button',\n className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`,\n title: 'GYXX \u5DE5\u4F5C\u6D41\u5DE5\u4F5C\u53F0',\n onClick: () => {\n const next = !getState().open\n update({ open: next })\n if (next) refreshWorkflows()\n },\n },\n createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),\n createElement('span', null, '\u5DE5\u4F5C\u6D41'),\n )\n}\n\n/** \u8986\u76D6\u5C42\u6839\uFF1A\u5DE6\u4FA7\u62BD\u5C49\u9762\u677F + \u5931\u8D25 toast\u3002 */\nfunction WorkbenchOverlay() {\n return createElement(\n 'div',\n { className: 'gyxxwb-overlay' },\n createElement(WorkflowPanel, null),\n createElement(Toasts, null),\n )\n}\n\nexport function apply(ctx) {\n injectStyles()\n // \u63D2\u69FD\u987B\u7B49\u5BBF\u4E3B\u58F0\u660E\u540E\u624D\u80FD\u6CE8\u518C\uFF1Aslots.inject \u4F1A\u7B49\u5F85\u5EA7\u4F4D\u58F0\u660E\uFF08\u987A\u5E8F\u5B89\u5168\uFF09\uFF0C\n // \u76F4\u63A5 register \u5728\u6211\u4EEC\u5148\u4E8E ui-sidebar/ui-layout \u52A0\u8F7D\u65F6\u4F1A\u62A5\u201Cslot is not declared\u201D\u3002\n ctx.effect(\n () =>\n ctx.slots.inject('sidebar.footer.action', () =>\n ctx.slots.register(\n { name: 'sidebar.footer.action', id: 'gyxx-workbench.footer' },\n WorkbenchFooterAction,\n ),\n ),\n 'gyxx-workbench: footer action',\n )\n ctx.effect(\n () =>\n ctx.slots.inject('shell.overlay', () =>\n ctx.slots.register(\n { name: 'shell.overlay', id: 'gyxx-workbench.overlay' },\n WorkbenchOverlay,\n ),\n ),\n 'gyxx-workbench: overlay',\n )\n}\n", "/** \u5DE5\u4F5C\u6D41\u9762\u677F\uFF1A\u4FA7\u8FB9\u62BD\u5C49\uFF0C\u5217\u51FA\u5168\u90E8\u5DE5\u4F5C\u6D41\uFF0C\u9009\u4E2D\u540E\u53EF\u63D0\u95EE / \u8BCA\u65AD / \u4FEE\u590D / \u542F\u505C\u3002 */\n\nimport { useSyncExternalStore, useEffect, useCallback } from 'react'\nimport { getState, subscribe, update, pushToast } from './store.js'\nimport { api } from './api.js'\n\nfunction useWorkbench() {\n return useSyncExternalStore(subscribe, getState)\n}\n\nconst STATUS_LABELS = {\n success: '\u6210\u529F',\n failed: '\u5931\u8D25',\n running: '\u8FD0\u884C\u4E2D',\n cancelled: '\u5DF2\u505C\u6B62',\n none: '\u672A\u8FD0\u884C',\n}\n\nfunction statusOf(workflow) {\n if (workflow.active_run) return 'running'\n return workflow.last_run?.status ?? 'none'\n}\n\nfunction formatTime(value) {\n if (!value) return '\u2014'\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return String(value)\n const pad = (n) => String(n).padStart(2, '0')\n return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`\n}\n\nfunction formatDuration(seconds) {\n if (seconds == null) return ''\n if (seconds < 60) return `${seconds}s`\n return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}`\n}\n\nexport async function refreshWorkflows() {\n update({ loading: true, error: null })\n try {\n const payload = await api.listWorkflows()\n update({\n loading: false,\n workflows: payload.workflows ?? [],\n summary: payload.summary ?? null,\n generatedAt: payload.generated_at ?? null,\n })\n } catch (error) {\n update({ loading: false, error: error.message })\n }\n}\n\nasync function selectWorkflow(workflowId) {\n update({ selectedId: workflowId, detLine truncated
"mappings": ";;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAMA,IAAAA,gBAAoD;;;ACJpD,mBAA6D;;;ACG7D,IAAM,YAAY,oBAAI,IAAI;AAE1B,IAAI,QAAQ;AAAA,EACV,MAAM;AAAA,EACN,SAAS;AAAA,EACT,OAAO;AAAA,EACP,aAAa;AAAA,EACb,SAAS;AAAA,EACT,WAAW,CAAC;AAAA,EACZ,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,eAAe;AAAA,EACf,MAAM,CAAC;AAAA,EACP,WAAW;AAAA,EACX,kBAAkB;AAAA,EAClB,UAAU;AAAA,EACV,cAAc,oBAAoB;AAAA,EAClC,QAAQ,CAAC;AAAA,EACT,cAAc,CAAC;AAAA,EACf,QAAQ,CAAC;AACX;AAEO,SAAS,sBAAsB;AACpC,QAAM,MAAM,oBAAI,KAAK;AACrB,MAAI,QAAQ,IAAI,QAAQ,IAAI,CAAC;AAC7B,QAAM,MAAM,CAAC,UAAU,OAAO,KAAK,EAAE,SAAS,GAAG,GAAG;AACpD,SAAO,GAAG,IAAI,YAAY,CAAC,IAAI,IAAI,IAAI,SAAS,IAAI,CAAC,CAAC,IAAI,IAAI,IAAI,QAAQ,CAAC,CAAC;AAC9E;AAEO,SAAS,WAAW;AACzB,SAAO;AACT;AAEO,SAAS,OAAO,OAAO;AAC5B,UAAQ,EAAE,GAAG,OAAO,GAAG,MAAM;AAC7B,aAAW,YAAY,UAAW,UAAS;AAC7C;AAEO,SAAS,UAAU,UAAU;AAClC,YAAU,IAAI,QAAQ;AACtB,SAAO,MAAM,UAAU,OAAO,QAAQ;AACxC;AAEO,SAAS,UAAU,OAAO;AAC/B,QAAM,KAAK,KAAK,KAAK,IAAI,EAAE,SAAS,EAAE,CAAC,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AACjF,SAAO,EAAE,QAAQ,CAAC,GAAG,MAAM,QAAQ,EAAE,IAAI,GAAG,MAAM,CAAC,EAAE,CAAC;AACtD,SAAO;AACT;AAEO,SAAS,aAAa,IAAI;AAC/B,SAAO,EAAE,QAAQ,MAAM,OAAO,OAAO,CAAC,UAAU,MAAM,OAAO,EAAE,EAAE,CAAC;AACpE;;;ACtDO,SAAS,aAAa;AAC3B,MAAI,OAAO,WAAW,aAAa;AACjC,QAAI,OAAO,mBAAoB,QAAO,OAAO;AAC7C,QAAI;AACF,YAAM,QAAQ,OAAO,cAAc,QAAQ,gBAAgB;AAC3D,UAAI,MAAO,QAAO;AAAA,IACpB,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAe,QAAQ,QAAQ,MAAM,MAAM;AACzC,QAAM,WAAW,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,IAAI,IAAI;AAAA,IACrD;AAAA,IACA,SAAS;AAAA,MACP,QAAQ;AAAA,MACR,GAAI,SAAS,SACT,EAAE,gBAAgB,oBAAoB,oBAAoB,IAAI,IAC9D,CAAC;AAAA,IACP;AAAA,IACA,MAAM,SAAS,SAAY,SAAY,KAAK,UAAU,IAAI;AAAA,EAC5D,CAAC;AACD,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,SAAS,KAAK;AAAA,EAChC,QAAQ;AACN,cAAU,EAAE,IAAI,OAAO,OAAO,gFAAyB,SAAS,MAAM,SAAI;AAAA,EAC5E;AACA,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,SAAS,SAAS,kDAAe,SAAS,MAAM,QAAG;AAAA,EACrE;AACA,SAAO;AACT;AAEO,IAAM,MAAM;AAAA,EACjB,eAAe,MAAM,QAAQ,OAAO,mBAAmB;AAAA,EACvD,gBAAgB,CAAC,eACf,QAAQ,OAAO,qBAAqB,mBAAmB,UAAU,CAAC,EAAE;AAAA,EACtE,cAAc,CAAC,YAAY,QAAQ,MACjC,QAAQ,OAAO,qBAAqB,mBAAmB,UAAU,CAAC,eAAe,KAAK,EAAE;AAAA,EAC1F,WAAW,CAAC,YAAY,UACtB;AAAA,IACE;AAAA,IACA,qBAAqB,mBAAmB,UAAU,CAAC,aAAa,QAAQ,WAAW,mBAAmB,KAAK,CAAC,KAAK,EAAE;AAAA,EACrH;AAAA,EACF,QAAQ,MAAM,QAAQ,OAAO,gBAAgB;AAAA,EAC7C,eAAe,CAAC,OAAO,QAAQ,QAAQ,uBAAuB,EAAE,GAAG,CAAC;AAAA,EACpE,SAAS,CAAC,YAAY,QAAQ,QAAQ,mBAAmB,OAAO;AAAA,EAChE,QAAQ,CAAC,YAAY,QAAQ,QAAQ,kBAAkB,OAAO;AAAA,EAC9D,KAAK,CAAC,YAAY,QAAQ,QAAQ,eAAe,OAAO;AAC1D;;;AFwFM;AAxIN,SAAS,eAAe;AACtB,aAAO,mCAAqB,WAAW,QAAQ;AACjD;AAEA,IAAM,gBAAgB;AAAA,EACpB,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,MAAM;AACR;AAEA,SAAS,SAAS,UAAU;AAC1B,MAAI,SAAS,WAAY,QAAO;AAChC,SAAO,SAAS,UAAU,UAAU;AACtC;AAEA,SAAS,WAAW,OAAO;AACzB,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO,OAAO,KAAK;AACrD,QAAM,MAAM,CAAC,MAAM,OAAO,CAAC,EAAE,SAAS,GAAG,GAAG;AAC5C,SAAO,GAAG,IAAI,KAAK,SAAS,IAAI,CAAC,CAAC,IAAI,IAAI,KAAK,QAAQ,CAAC,CAAC,IAAI,IAAI,KAAK,SAAS,CAAC,CAAC,IAAI,IAAI,KAAK,WAAW,CAAC,CAAC;AAC7G;AAEA,SAAS,eAAe,SAAS;AAC/B,MAAI,WAAW,KAAM,QAAO;AAC5B,MAAI,UAAU,GAAI,QAAO,GAAG,OAAO;AACnC,SAAO,GAAG,KAAK,MAAM,UAAU,EAAE,CAAC,IAAI,UAAU,KAAK,GAAG,UAAU,EAAE,MAAM,EAAE;AAC9E;AAEA,eAAsB,mBAAmB;AACvC,SAAO,EAAE,SAAS,MAAM,OAAO,KAAK,CAAC;AACrC,MAAI;AACF,UAAM,UAAU,MAAM,IAAI,cAAc;AACxC,WAAO;AAAA,MACL,SAAS;AAAA,MACT,WAAW,QAAQ,aAAa,CAAC;AAAA,MACjC,SAAS,QAAQ,WAAW;AAAA,MAC5B,aAAa,QAAQ,gBAAgB;AAAA,IACvC,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,CAAC;AAAA,EACjD;AACF;AAEA,eAAe,eAAe,YAAY;AACxC,SAAO,EAAE,YAAY,YAAY,eAAe,MAAM,WAAW,MAAM,MAAM,CAAC,EAAE,CAAC;AACjF,MAAI;AACF,UAAM,CAAC,QAAQ,IAAI,IAAI,MAAM,QAAQ,IAAI;AAAA,MACvC,IAAI,eAAe,UAAU,EAAE,MAAM,MAAM,IAAI;AAAA,MAC/C,IAAI,aAAa,YAAY,CAAC,EAAE,MAAM,OAAO,EAAE,MAAM,CAAC,EAAE,EAAE;AAAA,IAC5D,CAAC;AACD,WAAO,EAAE,eAAe,OAAO,QAAQ,MAAM,KAAK,QAAQ,CAAC,EAAE,CAAC;AAAA,EAChE,SAAS,OAAO;AACd,WAAO,EAAE,eAAe,OAAO,OAAO,MAAM,QAAQ,CAAC;AAAA,EACvD;AACF;AAEA,eAAe,cAAc,YAAY,OAAO;AAC9C,SAAO,EAAE,kBAAkB,KAAK,CAAC;AACjC,MAAI;AACF,UAAM,UAAU,MAAM,IAAI,UAAU,YAAY,KAAK;AACrD,WAAO,EAAE,kBAAkB,OAAO,WAAW,QAAQ,CAAC;AAAA,EACxD,SAAS,OAAO;AACd,WAAO,EAAE,kBAAkB,MAAM,CAAC;AAClC,cAAU,EAAE,MAAM,SAAS,MAAM,yDAAY,MAAM,OAAO,GAAG,CAAC;AAAA,EAChE;AACF;AAEA,eAAe,MAAM,QAAQ;AAC3B,QAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAE,WAAY;AACnB,QAAM,WAAW,EAAE,SAAS,KAAK;AACjC,MAAI;AACF,UAAM,UAAU,MAAM,IAAI,IAAI;AAAA,MAC5B,aAAa,EAAE;AAAA,MACf;AAAA,MACA;AAAA,IACF,CAAC;AACD,UAAM,QAAQ,EAAE,KAAK,gBAAM,UAAU,gBAAM,QAAQ,eAAK,EAAE,MAAM,KAAK;AACrE,cAAU;AAAA,MACR,MAAM;AAAA,MACN,MAAM,qBAAM,KAAK,qBAAM,OAAO,QAAQ,UAAU,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,IAChE,CAAC;AACD,WAAO,EAAE,UAAU,GAAG,CAAC;AAAA,EACzB,SAAS,OAAO;AACd,cAAU,EAAE,MAAM,SAAS,MAAM,6CAAU,MAAM,OAAO,GAAG,CAAC;AAAA,EAC9D;AACF;AAEA,eAAe,UAAU,SAAS;AAChC,QAAM,IAAI,SAAS;AACnB,MAAI,CAAC,EAAE,WAAY;AACnB,MAAI,SAAS;AACX,UAAM,KAAK,OAAO;AAAA,MAChB,4BAAQ,EAAE,UAAU,kCAAS,EAAE,YAAY;AAAA,IAC7C;AACA,QAAI,CAAC,GAAI;AAAA,EACX;AACA,MAAI;AACF,UAAM,IAAI,QAAQ;AAAA,MAChB,aAAa,EAAE;AAAA,MACf,eAAe,EAAE;AAAA,MACjB;AAAA,MACA,WAAW;AAAA,IACb,CAAC;AACD,cALine truncated
"names": ["import_react", "import_react", "import_jsx_runtime", "useWorkbench", "useWorkbench"]
}
-481
View File
@@ -1,481 +0,0 @@
{
"name": "@gyxx/dsh-plugin-gyxx-workbench",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gyxx/dsh-plugin-gyxx-workbench",
"version": "0.1.0",
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
}
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@gyxx/dsh-plugin-gyxx-workbench",
"version": "0.1.0",
"private": true,
"description": "GYXX 智能工作台:把 gyxx-flow 工作流暴露为 deepseek-harness 智能体工具与侧边栏面板",
"type": "module",
"main": "gyxx-workbench.mjs",
"exports": {
".": "./gyxx-workbench.mjs",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-locale"
]
}
},
"scripts": {
"build": "node scripts/build-client.mjs",
"test": "node --test \"tests/**/*.test.mjs\""
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
-51
View File
@@ -1,51 +0,0 @@
/**
* 构建客户端包:esbuild(CJS / browser / 平台模块外置)+ dsh 模块加载器包装。
* 包装协议与 dsh 官方 tsdown 产物一致:
* window.__ModuleLoader__.load({ id, factory: (require) => { ... return module.exports } })
*/
import { build } from 'esbuild'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const PKG_ID = '@gyxx/dsh-plugin-gyxx-workbench'
// dsh 平台共享模块表(packages/client/web/src/platform.ts 的 PLATFORM_MODULES)
const PLATFORM_EXTERNALS = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'@deepseek-ai/cordis',
'@deepseek-ai/dsh-client-store',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
]
await build({
absWorkingDir: root,
entryPoints: ['client/src/index.jsx'],
bundle: true,
format: 'cjs',
platform: 'browser',
target: 'es2020',
outfile: 'lib/client.js',
external: PLATFORM_EXTERNALS,
loader: { '.css': 'text' },
jsx: 'automatic',
sourcemap: true,
minify: false,
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
},
banner: {
// dsh 浏览器加载器只给 factory 传 require(返回值即模块导出),
// CJS 的 module/exports 垫片必须由包体自带(与官方 tsdown 产物一致)。
js: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PKG_ID)}, factory: (require) => {\nvar module = { exports: {} };\nvar exports = module.exports;`,
},
footer: { js: 'return module.exports; } });' },
logLevel: 'info',
})
console.log('built lib/client.js')
-164
View File
@@ -1,164 +0,0 @@
/**
* gyxx-workbench 宿主插件冒烟测试:
* - apply() 能注册 7 个工具与系统提示词
* - 工具在控制台离线时返回结构化错误(不抛出)
* - 桥接服务能代理控制台 API 并为提问创建会话
* 运行:node --test tests/
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import * as plugin from '../gyxx-workbench.mjs'
function makeCtx() {
const registered = { tools: [], sections: [], effects: [], emissions: [] }
const agents = {
calls: [],
async create(options) {
agents.calls.push(options)
return {
agent: {
followups: [],
followup(message) {
this.followups.push(message)
agents.calls[agents.calls.length - 1].followup = message
},
},
dispose() {},
}
},
}
const ctx = {
tools: { register: (def) => registered.tools.push(def) },
systemPrompt: { section: (section) => registered.sections.push(section) },
effect(fn) {
registered.effects.push(fn)
return undefined
},
emit: (event, payload) => registered.emissions.push([event, payload]),
get: (key) => (key === 'agents' ? agents : undefined),
logger: { info() {} },
}
return { ctx, registered, agents }
}
test('apply 注册 7 个工作流工具与系统提示词', () => {
const { ctx, registered } = makeCtx()
plugin.apply(ctx, { bridgePort: 0, pollIntervalMs: 60_000 })
const names = registered.tools.map((tool) => tool.name).sort()
assert.deepEqual(names, [
'gyxx_schedule_update',
'gyxx_workflow_cancel',
'gyxx_workflow_detail',
'gyxx_workflow_diagnose',
'gyxx_workflow_list',
'gyxx_workflow_runs',
'gyxx_workflow_trigger',
])
assert.equal(registered.sections.length, 1)
assert.equal(registered.sections[0].name, 'gyxx-workbench')
assert.match(registered.sections[0].text, /安全契约/)
// 监控器 + 桥接服务两个 effect
assert.equal(registered.effects.length, 2)
})
test('工具在控制台离线时返回结构化错误而非抛出', async () => {
const { ctx, registered } = makeCtx()
plugin.apply(ctx, {
consoleBaseUrl: 'http://127.0.0.1:1',
bridgePort: 0,
pollIntervalMs: 60_000,
})
const listTool = registered.tools.find((tool) => tool.name === 'gyxx_workflow_list')
const result = await listTool.execute({})
assert.equal(result.ok, false)
assert.match(result.error, /无法连接 gyxx 控制台/)
})
test('正式执行缺少 confirmed 时工具直接拒绝', async () => {
const { ctx, registered } = makeCtx()
plugin.apply(ctx, { consoleBaseUrl: 'http://127.0.0.1:1', bridgePort: 0 })
const trigger = registered.tools.find((tool) => tool.name === 'gyxx_workflow_trigger')
const result = await trigger.execute({
workflow_id: 'content.metrics.daily',
business_date: '2026-08-01',
execute: true,
})
assert.equal(result.ok, false)
assert.match(result.error, /确认/)
})
test('桥接服务代理控制台 API 并通过 /bridge/ask 创建会话', async (t) => {
// 1) 模拟 gyxx 控制台
const consoleServer = http.createServer((req, res) => {
if (req.url === '/api/overview') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(
JSON.stringify({
workflows: [
{
id: 'content.metrics.daily',
name: '内容指标日报',
module: 'content_marketing',
trigger: 'scheduled',
registered: true,
schedule: { enabled: true, kind: 'daily', at: '09:00' },
last_run: {
run_id: 'run-1',
business_date: '2026-08-01',
mode: 'execute',
shadow: false,
status: 'failed',
started_at: '2026-08-01T01:00:00+00:00',
ended_at: '2026-08-01T01:05:00+00:00',
error: 'step failed',
step_counts: { failed: 1 },
},
active_run: null,
},
],
summary: { total: 1 },
}),
)
return
}
if (req.url === '/api/workflows/content.metrics.daily') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ workflow: { id: 'content.metrics.daily', name: '内容指标日报' } }))
return
}
res.writeHead(404, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: '页面或接口不存在' }))
})
await new Promise((resolve) => consoleServer.listen(0, '127.0.0.1', resolve))
t.after(() => consoleServer.close())
const consolePort = consoleServer.address().port
// 2) 以 mock ctx 加载插件并手动启动桥接 effect
const { ctx, registered, agents } = makeCtx()
plugin.apply(ctx, {
consoleBaseUrl: `http://127.0.0.1:${consolePort}`,
bridgePort: 0,
pollIntervalMs: 600_000,
})
const bridgeEffect = registered.effects[1]
const dispose = await bridgeEffect()
t.after(() => dispose())
// 桥接端口由 effect 内部 listen 决定,通过日志无法读取——改为直接请求
// 控制台代理行为经工具路径验证;这里验证 ask 会话创建逻辑。
const listTool = registered.tools.find((tool) => tool.name === 'gyxx_workflow_list')
const list = await listTool.execute({})
assert.equal(list.ok, true)
assert.equal(list.workflows.length, 1)
assert.equal(list.workflows[0].last_run.status, 'failed')
assert.equal(list.workflows[0].module_label, '内容营销')
// diagnose:自动选择最近失败运行(控制台无该路由时应返回结构化错误)
const diagnose = registered.tools.find((tool) => tool.name === 'gyxx_workflow_diagnose')
const missing = await diagnose.execute({ workflow_id: 'content.metrics.daily' })
assert.equal(missing.ok, false)
assert.match(missing.error, /页面或接口不存在|HTTP 404/)
})