From ee3e72ea82e740cbe8158612b05daa0953e8bbbc Mon Sep 17 00:00:00 2001
From: lunnt <2276214182@qq.com>
Date: Mon, 6 Jul 2026 04:07:54 +0800
Subject: [PATCH 1/2] Add opinion monitor quickstart
---
.gitignore | 4 +
README.md | 46 ++
backend/agent_server.py | 45 +-
backend/api/v1/dashboard.py | 273 ++++++++-
backend/api/v1/presets.py | 225 ++++++-
backend/notifiers/feishu_notifier.py | 30 +-
backend/pipeline/runner.py | 16 +-
backend/ws_agent_manager.py | 22 +-
frontend/app/(app)/dashboard/page.tsx | 122 +++-
frontend/lib/api/endpoints.ts | 8 +
frontend/lib/api/hooks.ts | 8 +
frontend/lib/api/types.ts | 38 ++
scripts/acceptance/fleet-acceptance.ps1 | 775 ++++++++++++++++++++++++
tests/integration/test_dashboard_api.py | 68 +++
tests/integration/test_presets_api.py | 76 +++
tests/unit/test_messaging_notifiers.py | 53 +-
16 files changed, 1762 insertions(+), 47 deletions(-)
create mode 100644 scripts/acceptance/fleet-acceptance.ps1
diff --git a/.gitignore b/.gitignore
index cbf3a61..e192368 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,3 +81,7 @@ odp-rs/target/
.gstack/
.repowise/
.understand-anything/
+.codex/
+.sentrux/agent-sessions/
+.sentrux/baseline.json
+artifacts/
diff --git a/README.md b/README.md
index 499e9fa..52fa5ba 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,52 @@ docker compose up -d # 启动中心 + agent-1
---
+### 舆情监控实战闭环
+
+当前实战链路已经投到真实运行面,而不是只停留在配置说明:
+
+1. **多账号 / 多节点采集** — 通过「节点管理」和站点绑定,把 `opencli` 采集路由到指定 WS agent;验收脚本会证明 `chrome_endpoint` 和 `node_url` 都落在绑定节点。
+2. **AI 摘要与打标** — `collect → normalize → store → ai → notify` 流水线会把模型输出写入 `ai_enrichment`,监控台读取真实记录展示摘要、标签和情绪分布。
+3. **飞书推送** — 飞书模板可以直接引用 `{{summary}}`、`{{tags}}`、`{{sentiment}}`,把 AI 处理后的内容推到群机器人。
+4. **可视化验收** — 「监控台」的舆情监控卡片读取 `/api/v1/dashboard/opinion-monitor`,展示最近热点、AI 处理量、Feishu sent/failed 证据和来源贡献。
+
+一键生成实战配置:
+
+```bash
+curl -X POST http://localhost:8000/api/v1/presets/opinion-monitor/apply \
+ -H "Content-Type: application/json" \
+ -d '{
+ "source_prefix": "实战舆情",
+ "feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
+ }'
+```
+
+这会创建两条默认 `aibase news` 多账号采集源、对应定时计划,以及一个飞书规则。
+如果暂时不填 `feishu_webhook_url`,飞书规则会以 disabled 状态创建,不会伪造推送成功。
+
+关键验收命令:
+
+```powershell
+scripts\acceptance\fleet-acceptance.ps1 `
+ -Site aibase `
+ -Command news `
+ -Limit 1 `
+ -CenterPort 8032 `
+ -AgentPort 19824 `
+ -FreshDb
+```
+
+如果本机已有旧 API/agent 进程占用端口,可以换一组固定端口,例如
+`-CenterPort 8035 -AgentPort 19828`。
+
+最终应输出:
+
+```text
+ACCEPTANCE: PASS
+```
+
+---
+
## 快速开始
### 方式零:前后端本地开发(推荐)
diff --git a/backend/agent_server.py b/backend/agent_server.py
index 913e9eb..561c98e 100644
--- a/backend/agent_server.py
+++ b/backend/agent_server.py
@@ -91,7 +91,8 @@ def _resolve_bin(mode: str) -> str: # noqa: ARG001
_CENTRAL_API_URL = os.environ.get("CENTRAL_API_URL", "").rstrip("/")
_AGENT_ADVERTISE_URL = os.environ.get("AGENT_ADVERTISE_URL", "")
_AGENT_MODE = os.environ.get("AGENT_MODE", "cdp")
-# Deployment/startup type reported to center: "docker" (running in container) | "shell" (native process)
+# Deployment/startup type reported to center:
+# "docker" (container) | "shell" (native process).
_AGENT_DEPLOY_TYPE = os.environ.get("AGENT_DEPLOY_TYPE", "docker")
# True when the image was built with INSTALL_CHROME=true (Chrome bundled inside container).
# False → Chrome runs on the host; localhost must be remapped to host.docker.internal.
@@ -99,7 +100,8 @@ def _resolve_bin(mode: str) -> str: # noqa: ARG001
_AGENT_LABEL = os.environ.get("AGENT_LABEL", socket.gethostname())
# Registration mode:
# http — LAN mode: agent POSTs its URL to center, center calls back via HTTP (default)
-# ws — NAT/reverse-channel mode: agent opens WS to center, registration via WS handshake (Phase 2)
+# ws — NAT/reverse-channel mode: agent opens WS to center, then
+# registers through the WS handshake.
# off — disable auto-registration entirely
_AGENT_REGISTER = os.environ.get("AGENT_REGISTER", "http").lower()
# opencli subprocess execution timeout in seconds
@@ -191,7 +193,12 @@ async def _register_with_center(advertise_url: str) -> None:
return
except Exception as exc:
wait = attempt * 3
- logger.warning("Registration attempt %d failed: %s — retrying in %ds", attempt, exc, wait)
+ logger.warning(
+ "Registration attempt %d failed: %s — retrying in %ds",
+ attempt,
+ exc,
+ wait,
+ )
await asyncio.sleep(wait)
logger.error("Could not register with center after 5 attempts")
@@ -281,7 +288,11 @@ async def _send_result(result: dict) -> None:
"event": event,
}))
except Exception as exc:
- logger.error("WS: failed to send agent_event for request_id=%s: %s", request_id, exc)
+ logger.error(
+ "WS: failed to send agent_event for request_id=%s: %s",
+ request_id,
+ exc,
+ )
if terminal_event is None:
# Contract violation (adapter yielded nothing) — still must resolve
# the center's pending future rather than hang it until timeout.
@@ -293,7 +304,11 @@ async def _send_result(result: dict) -> None:
}
await _send_result(terminal_event)
except RuntimeInvocationError as exc:
- logger.exception("WS agent_task request_id=%s: adapter invocation error: %s", request_id, exc)
+ logger.exception(
+ "WS agent_task request_id=%s: adapter invocation error: %s",
+ request_id,
+ exc,
+ )
await _send_result({
"type": "error",
"task_id": request_id,
@@ -409,11 +424,19 @@ async def lifespan(app: FastAPI):
_CENTRAL_API_URL or "", _AGENT_REGISTER)
elif _AGENT_REGISTER == "http":
advertise_url = _detect_advertise_url()
- logger.info("LAN registration: advertise_url=%s → center=%s", advertise_url, _CENTRAL_API_URL)
+ logger.info(
+ "LAN registration: advertise_url=%s → center=%s",
+ advertise_url,
+ _CENTRAL_API_URL,
+ )
asyncio.get_event_loop().create_task(_register_with_center(advertise_url))
elif _AGENT_REGISTER == "ws":
advertise_url = _detect_advertise_url()
- logger.info("WS registration: advertise_url=%s → center=%s", advertise_url, _CENTRAL_API_URL)
+ logger.info(
+ "WS registration: advertise_url=%s → center=%s",
+ advertise_url,
+ _CENTRAL_API_URL,
+ )
_ws_task = asyncio.get_event_loop().create_task(_register_via_ws(advertise_url))
yield
if _ws_task and not _ws_task.done():
@@ -468,7 +491,11 @@ async def _cleanup_cdp_tabs(cdp_endpoint: str, pre_existing_ids: set[str]) -> No
if tab.get("type") == "page" and tab_id not in pre_existing_ids:
try:
await client.get(f"{cdp_endpoint}/json/close/{tab_id}")
- logger.info("cleanup: closed new tab %s url=%s", tab_id, tab.get("url", "")[:80])
+ logger.info(
+ "cleanup: closed new tab %s url=%s",
+ tab_id,
+ tab.get("url", "")[:80],
+ )
remaining_pages -= 1
except Exception:
pass
@@ -560,7 +587,7 @@ async def collect(req: CollectRequest) -> dict:
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_OPENCLI_TIMEOUT)
rc = proc.returncode
- except asyncio.TimeoutError:
+ except TimeoutError:
logger.error("timeout | cmd=%s", " ".join(cmd))
if proc:
proc.kill()
diff --git a/backend/api/v1/dashboard.py b/backend/api/v1/dashboard.py
index 01d364f..d5c7ea3 100644
--- a/backend/api/v1/dashboard.py
+++ b/backend/api/v1/dashboard.py
@@ -1,13 +1,15 @@
"""Dashboard statistics endpoint."""
-from datetime import datetime, timezone, timedelta, date
-from typing import Optional
+from collections import Counter, defaultdict
+from datetime import UTC, datetime, timedelta
+from typing import Any
from fastapi import APIRouter, Depends, Query
-from sqlalchemy import func, select, case
+from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
+from backend.models.notification import NotificationLog, NotificationRule
from backend.models.record import CollectedRecord
from backend.models.source import DataSource
from backend.models.task import CollectionTask, TaskRun
@@ -16,13 +18,68 @@
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
+def _display_text(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, list):
+ return "、".join(str(item) for item in value if str(item).strip())
+ if isinstance(value, dict):
+ return "、".join(f"{key}: {val}" for key, val in value.items())
+ return str(value)
+
+
+def _title_from_record(record: CollectedRecord) -> str:
+ data = record.normalized_data or record.raw_data or {}
+ for key in ("title", "name", "text", "content", "url"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return "(无标题)"
+
+
+def _url_from_record(record: CollectedRecord) -> str | None:
+ data = record.normalized_data or record.raw_data or {}
+ value = data.get("url") or data.get("link")
+ return value if isinstance(value, str) and value.strip() else None
+
+
+def _summary_from_ai(ai: dict[str, Any] | None) -> str:
+ if not ai:
+ return ""
+ for key in ("summary", "abstract", "brief", "摘要"):
+ if value := _display_text(ai.get(key)):
+ return value
+ return ""
+
+
+def _tags_from_ai(ai: dict[str, Any] | None) -> list[str]:
+ if not ai:
+ return []
+ raw = ai.get("tags") or ai.get("labels") or ai.get("keywords") or ai.get("关键词")
+ if isinstance(raw, list):
+ return [str(item).strip() for item in raw if str(item).strip()]
+ if isinstance(raw, str):
+ return [part.strip() for part in raw.replace(",", ",").split(",") if part.strip()]
+ return []
+
+
+def _sentiment_from_ai(ai: dict[str, Any] | None) -> str:
+ if not ai:
+ return "unknown"
+ raw = ai.get("sentiment") or ai.get("情绪") or ai.get("polarity")
+ if isinstance(raw, dict):
+ raw = raw.get("label") or raw.get("value")
+ value = str(raw).strip().lower() if raw is not None else ""
+ return value or "unknown"
+
+
def _parse_time_range(
range: str,
- start: Optional[datetime],
- end: Optional[datetime],
-) -> tuple[Optional[datetime], Optional[datetime]]:
+ start: datetime | None,
+ end: datetime | None,
+) -> tuple[datetime | None, datetime | None]:
"""Return (since, until) UTC datetimes for the given range string."""
- now = datetime.now(timezone.utc)
+ now = datetime.now(UTC)
if range == "today":
since = now.replace(hour=0, minute=0, second=0, microsecond=0)
return since, None
@@ -41,9 +98,12 @@ def _parse_time_range(
@router.get("/stats", response_model=ApiResponse[dict])
async def get_stats(
- range: str = Query("all", description="Time range: all | today | yesterday | 7d | 30d | custom"),
- start: Optional[datetime] = Query(None, description="Custom range start (ISO 8601, UTC)"),
- end: Optional[datetime] = Query(None, description="Custom range end (ISO 8601, UTC)"),
+ range: str = Query(
+ "all",
+ description="Time range: all | today | yesterday | 7d | 30d | custom",
+ ),
+ start: datetime | None = Query(None, description="Custom range start (ISO 8601, UTC)"),
+ end: datetime | None = Query(None, description="Custom range end (ISO 8601, UTC)"),
db: AsyncSession = Depends(get_db),
) -> ApiResponse:
since, until = _parse_time_range(range, start, end)
@@ -159,14 +219,19 @@ async def get_stats(
@router.get("/activity", response_model=ApiResponse[dict])
async def get_activity(
days: int = Query(7, ge=1, le=30, description="Number of past days to include"),
- tz_offset: int = Query(8, ge=-12, le=14, description="Client UTC offset in hours (default: +8 CST)"),
+ tz_offset: int = Query(
+ 8,
+ ge=-12,
+ le=14,
+ description="Client UTC offset in hours (default: +8 CST)",
+ ),
db: AsyncSession = Depends(get_db),
) -> ApiResponse:
"""Return per-day task run and record counts for the past N days.
Uses the client's UTC offset so that day boundaries align with local time.
"""
- now_utc = datetime.now(timezone.utc)
+ now_utc = datetime.now(UTC)
tz_delta = timedelta(hours=tz_offset)
now_local = now_utc + tz_delta
today_local = now_local.date()
@@ -174,7 +239,7 @@ async def get_activity(
# Build list of dates from (today - days + 1) to today
date_range = [today_local - timedelta(days=i) for i in range(days - 1, -1, -1)]
since_utc = datetime.combine(date_range[0], datetime.min.time()) - tz_delta
- since_utc = since_utc.replace(tzinfo=timezone.utc)
+ since_utc = since_utc.replace(tzinfo=UTC)
# ── Task runs grouped by local date ──────────────────────────────────────
# Shift created_at to local time before truncating to date
@@ -195,7 +260,10 @@ async def get_activity(
# ── Records grouped by local date ─────────────────────────────────────────
local_rec_date_expr = func.date(
- func.datetime(CollectedRecord.created_at, f"{'+' if tz_offset >= 0 else ''}{tz_offset} hours")
+ func.datetime(
+ CollectedRecord.created_at,
+ f"{'+' if tz_offset >= 0 else ''}{tz_offset} hours",
+ )
)
recs_q = (
select(
@@ -221,3 +289,180 @@ async def get_activity(
})
return ApiResponse.ok({"daily": daily})
+
+
+@router.get("/opinion-monitor", response_model=ApiResponse[dict])
+async def get_opinion_monitor(
+ range: str = Query(
+ "7d",
+ description="Time range: all | today | yesterday | 7d | 30d | custom",
+ ),
+ start: datetime | None = Query(None, description="Custom range start (ISO 8601, UTC)"),
+ end: datetime | None = Query(None, description="Custom range end (ISO 8601, UTC)"),
+ limit: int = Query(20, ge=1, le=100, description="Recent records to return"),
+ db: AsyncSession = Depends(get_db),
+) -> ApiResponse:
+ """Opinion-monitor projection over real collection, AI, and push evidence.
+
+ This is deliberately read-only: it summarizes records, AI enrichment, and
+ Feishu notification logs already produced by the pipeline instead of
+ pretending to send or enrich anything at dashboard read time.
+ """
+ since, until = _parse_time_range(range, start, end)
+
+ def apply_window(query):
+ if since:
+ query = query.where(CollectedRecord.created_at >= since)
+ if until:
+ query = query.where(CollectedRecord.created_at < until)
+ return query
+
+ total_records = (
+ await db.execute(apply_window(select(func.count()).select_from(CollectedRecord)))
+ ).scalar_one()
+ ai_processed_records = (
+ await db.execute(
+ apply_window(
+ select(func.count())
+ .select_from(CollectedRecord)
+ .where(CollectedRecord.ai_enrichment.is_not(None))
+ )
+ )
+ ).scalar_one()
+ active_sources = (
+ await db.execute(
+ apply_window(select(func.count(func.distinct(CollectedRecord.source_id))))
+ )
+ ).scalar_one()
+
+ feishu_status_rows = await db.execute(
+ apply_window(
+ select(NotificationLog.status, func.count())
+ .select_from(NotificationLog)
+ .join(CollectedRecord, NotificationLog.record_id == CollectedRecord.id)
+ .join(NotificationRule, NotificationLog.rule_id == NotificationRule.id)
+ .where(NotificationRule.notifier_type == "feishu")
+ .group_by(NotificationLog.status)
+ )
+ )
+ feishu_status_counts = {
+ status: int(count) for status, count in feishu_status_rows.all()
+ }
+
+ records_query = (
+ select(CollectedRecord, DataSource)
+ .join(DataSource, CollectedRecord.source_id == DataSource.id)
+ .order_by(CollectedRecord.created_at.desc())
+ .limit(max(limit, 100))
+ )
+ records_query = apply_window(records_query)
+
+ rows = (await db.execute(records_query)).all()
+ records = [record for record, _source in rows]
+ record_ids = [record.id for record in records]
+
+ notification_by_record: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
+ if record_ids:
+ notification_rows = await db.execute(
+ select(
+ NotificationLog.record_id,
+ NotificationLog.status,
+ NotificationRule.notifier_type,
+ )
+ .join(NotificationRule, NotificationLog.rule_id == NotificationRule.id)
+ .where(NotificationLog.record_id.in_(record_ids))
+ )
+ for record_id, status, notifier_type in notification_rows.all():
+ if record_id and notifier_type == "feishu":
+ notification_by_record[record_id][status] += 1
+
+ tag_counts: Counter[str] = Counter()
+ sentiment_counts: Counter[str] = Counter()
+ source_rows: dict[str, dict[str, Any]] = {}
+ recent = []
+
+ for record, source in rows:
+ tags = _tags_from_ai(record.ai_enrichment)
+ sentiment = _sentiment_from_ai(record.ai_enrichment)
+ tag_counts.update(tags)
+ sentiment_counts.update([sentiment])
+
+ source_bucket = source_rows.setdefault(
+ source.id,
+ {
+ "id": source.id,
+ "name": source.name,
+ "channel_type": source.channel_type,
+ "records": 0,
+ "ai_processed": 0,
+ "feishu_sent": 0,
+ "feishu_failed": 0,
+ },
+ )
+ source_bucket["records"] += 1
+ if record.ai_enrichment:
+ source_bucket["ai_processed"] += 1
+
+ notify_counts = notification_by_record.get(record.id, {})
+ sent_count = int(notify_counts.get("sent", 0))
+ failed_count = int(notify_counts.get("failed", 0))
+ source_bucket["feishu_sent"] += sent_count
+ source_bucket["feishu_failed"] += failed_count
+
+ if len(recent) < limit:
+ notification_status = (
+ "sent" if sent_count else "failed" if failed_count else "pending"
+ )
+ recent.append(
+ {
+ "id": record.id,
+ "source_id": source.id,
+ "source_name": source.name,
+ "title": _title_from_record(record),
+ "url": _url_from_record(record),
+ "summary": _summary_from_ai(record.ai_enrichment),
+ "tags": tags,
+ "sentiment": sentiment,
+ "status": record.status,
+ "notification_status": notification_status,
+ "created_at": record.created_at.isoformat(),
+ }
+ )
+
+ feishu_rules_query = select(func.count()).select_from(NotificationRule).where(
+ NotificationRule.enabled.is_(True),
+ NotificationRule.notifier_type == "feishu",
+ )
+ active_feishu_rules = (await db.execute(feishu_rules_query)).scalar_one()
+
+ return ApiResponse.ok(
+ {
+ "window": {
+ "range": range,
+ "since": since.isoformat() if since else None,
+ "until": until.isoformat() if until else None,
+ },
+ "summary": {
+ "records": total_records,
+ "ai_processed": ai_processed_records,
+ "feishu_sent": feishu_status_counts.get("sent", 0),
+ "feishu_failed": feishu_status_counts.get("failed", 0),
+ "active_sources": active_sources,
+ "active_feishu_rules": active_feishu_rules,
+ },
+ "tags": [
+ {"label": label, "count": count}
+ for label, count in tag_counts.most_common(12)
+ ],
+ "sentiment": [
+ {"label": label, "count": count}
+ for label, count in sentiment_counts.most_common()
+ ],
+ "sources": sorted(
+ source_rows.values(),
+ key=lambda row: (row["records"], row["ai_processed"]),
+ reverse=True,
+ ),
+ "recent": recent,
+ }
+ )
diff --git a/backend/api/v1/presets.py b/backend/api/v1/presets.py
index fac2bec..9a1633e 100644
--- a/backend/api/v1/presets.py
+++ b/backend/api/v1/presets.py
@@ -5,16 +5,124 @@
"""
import logging
+from typing import Any
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.database import get_db
+from backend.models.notification import NotificationRule
from backend.plan_ir.presets import Preset, list_presets_grouped
from backend.schemas.common import ApiResponse
+from backend.schemas.schedule import CronScheduleCreate
+from backend.schemas.source import DataSourceCreate
+from backend.services import schedule_service, source_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/presets", tags=["presets"])
+OPINION_MONITOR_PROMPT = (
+ "你是舆情监控助手。分析下面采集记录,只返回 JSON 对象:"
+ '{"summary":"一句话摘要","tags":["关键词"],"sentiment":"positive|neutral|negative",'
+ '"category":"类别"}。\n'
+ "标题: {{title}}\n内容: {{content}}{{text}}{{description}}\n链接: {{url}}"
+)
+
+
+class OpinionMonitorAccountSlot(BaseModel):
+ label: str = Field(..., min_length=1, max_length=80)
+ site: str = Field("aibase", min_length=1, max_length=80)
+ command: str = Field("news", min_length=1, max_length=80)
+ limit: int = Field(5, ge=1, le=100)
+ cron_expression: str = Field("*/30 * * * *", description="5-field cron expression")
+ timezone: str = "Asia/Shanghai"
+
+
+def _default_opinion_slots() -> list[OpinionMonitorAccountSlot]:
+ return [
+ OpinionMonitorAccountSlot(label="aibase-account-a"),
+ OpinionMonitorAccountSlot(label="aibase-account-b"),
+ ]
+
+
+class OpinionMonitorApplyRequest(BaseModel):
+ source_prefix: str = Field("舆情监控", min_length=1, max_length=120)
+ account_slots: list[OpinionMonitorAccountSlot] = Field(
+ default_factory=_default_opinion_slots
+ )
+ source_enabled: bool = True
+ create_schedules: bool = True
+ schedule_enabled: bool = True
+ feishu_webhook_url: str | None = None
+ feishu_secret: str | None = None
+ notification_enabled: bool = True
+ notification_name: str = Field("舆情监控飞书推送", min_length=1, max_length=255)
+
+
+def _opinion_source_payload(
+ body: OpinionMonitorApplyRequest,
+ slot: OpinionMonitorAccountSlot,
+) -> dict[str, Any]:
+ return {
+ "name": f"{body.source_prefix} · {slot.label}",
+ "description": "Opinion monitoring quickstart source generated from presets.",
+ "channel_type": "opencli",
+ "channel_config": {
+ "site": slot.site,
+ "command": slot.command,
+ "format": "json",
+ "args": {"limit": slot.limit},
+ "account_label": slot.label,
+ "resource_policy": {
+ "site_binding": slot.site,
+ "account_label": slot.label,
+ "routing": "site_binding_agent_first",
+ },
+ },
+ "ai_config": {
+ "processor_type": "openai",
+ "prompt_template": OPINION_MONITOR_PROMPT,
+ "json_mode": True,
+ },
+ "enabled": body.source_enabled,
+ "tags": ["opinion-monitor", slot.site, slot.label],
+ }
+
+
+def _opinion_bundle_preview(body: OpinionMonitorApplyRequest) -> dict[str, Any]:
+ feishu_configured = bool(body.feishu_webhook_url)
+ return {
+ "id": "opinion-monitor.visual-feed.v1",
+ "label": "可视化舆情监控",
+ "description": "多账号 OpenCLI 采集 + AI 摘要打标 + 飞书推送 + 监控台投影",
+ "source_count": len(body.account_slots),
+ "sources": [_opinion_source_payload(body, slot) for slot in body.account_slots],
+ "schedules": [
+ {
+ "name": f"{body.source_prefix} · {slot.label} · 定时采集",
+ "cron_expression": slot.cron_expression,
+ "timezone": slot.timezone,
+ "parameters": {"limit": slot.limit},
+ "enabled": body.schedule_enabled,
+ }
+ for slot in body.account_slots
+ ],
+ "notification": {
+ "name": body.notification_name,
+ "notifier_type": "feishu",
+ "trigger_event": "on_new_record",
+ "enabled": body.notification_enabled and feishu_configured,
+ "requires_config": not feishu_configured,
+ "template_fields": ["title", "url", "summary", "tags", "sentiment"],
+ },
+ "visualization": {
+ "dashboard": "/dashboard",
+ "api": "/api/v1/dashboard/opinion-monitor",
+ },
+ }
+
@router.get("", response_model=ApiResponse[dict[str, list[Preset]]])
async def get_presets() -> ApiResponse:
@@ -24,3 +132,118 @@ async def get_presets() -> ApiResponse:
open."""
grouped = await list_presets_grouped()
return ApiResponse.ok(grouped)
+
+
+@router.get("/opinion-monitor", response_model=ApiResponse[dict])
+async def get_opinion_monitor_preset() -> ApiResponse:
+ """Read-only preview of the practical opinion-monitor quickstart bundle."""
+ return ApiResponse.ok(_opinion_bundle_preview(OpinionMonitorApplyRequest()))
+
+
+@router.post("/opinion-monitor/apply", response_model=ApiResponse[dict], status_code=201)
+async def apply_opinion_monitor_preset(
+ body: OpinionMonitorApplyRequest,
+ db: AsyncSession = Depends(get_db),
+) -> ApiResponse:
+ """Create sources, schedules, and a Feishu rule for the opinion monitor."""
+ created_sources = []
+ created_schedules = []
+ warnings = []
+
+ for slot in body.account_slots:
+ if body.create_schedules and not schedule_service.validate_cron_expression(
+ slot.cron_expression
+ ):
+ warnings.append(
+ {
+ "slot": slot.label,
+ "warning": "invalid_cron_expression",
+ "cron_expression": slot.cron_expression,
+ }
+ )
+ continue
+
+ source_data = DataSourceCreate(**_opinion_source_payload(body, slot))
+ source = await source_service.create_source(db, source_data)
+ created_sources.append(
+ {
+ "id": source.id,
+ "name": source.name,
+ "site": slot.site,
+ "command": slot.command,
+ "account_label": slot.label,
+ }
+ )
+
+ if body.create_schedules:
+ schedule = await schedule_service.create_schedule(
+ db,
+ CronScheduleCreate(
+ source_id=source.id,
+ name=f"{source.name} · 定时采集",
+ cron_expression=slot.cron_expression,
+ timezone=slot.timezone,
+ parameters={"limit": slot.limit},
+ enabled=body.schedule_enabled,
+ ),
+ )
+ created_schedules.append(
+ {
+ "id": schedule.id,
+ "source_id": source.id,
+ "cron_expression": schedule.cron_expression,
+ "timezone": schedule.timezone,
+ "enabled": schedule.enabled,
+ }
+ )
+
+ feishu_configured = bool(body.feishu_webhook_url)
+ if not feishu_configured:
+ warnings.append(
+ {
+ "warning": "feishu_webhook_url_missing",
+ "detail": "Feishu rule is created disabled until webhook_url is configured.",
+ }
+ )
+
+ rule = NotificationRule(
+ name=body.notification_name,
+ source_id=None,
+ trigger_event="on_new_record",
+ notifier_type="feishu",
+ notifier_config={
+ "webhook_url": body.feishu_webhook_url or "",
+ "secret": body.feishu_secret or "",
+ "title": "【舆情】{{title}}",
+ "content": (
+ "**摘要**:{{summary}}\n"
+ "**标签**:{{tags}}\n"
+ "**情绪**:{{sentiment}}\n"
+ "**链接**:{{url}}"
+ ),
+ },
+ enabled=body.notification_enabled and feishu_configured,
+ )
+ db.add(rule)
+ await db.flush()
+ await db.refresh(rule)
+
+ return ApiResponse.ok(
+ {
+ "preset_id": "opinion-monitor.visual-feed.v1",
+ "sources": created_sources,
+ "schedules": created_schedules,
+ "notification_rule": {
+ "id": rule.id,
+ "name": rule.name,
+ "enabled": rule.enabled,
+ "requires_config": not feishu_configured,
+ },
+ "warnings": warnings,
+ "next": {
+ "bind_site_to_ws_agent": "/api/v1/browsers/bindings",
+ "trigger": "/api/v1/tasks/trigger",
+ "monitor": "/api/v1/dashboard/opinion-monitor",
+ },
+ }
+ )
diff --git a/backend/notifiers/feishu_notifier.py b/backend/notifiers/feishu_notifier.py
index 158b824..11c1aba 100644
--- a/backend/notifiers/feishu_notifier.py
+++ b/backend/notifiers/feishu_notifier.py
@@ -18,6 +18,25 @@ def _render(template: str, data: dict[str, Any]) -> str:
return _PLACEHOLDER_RE.sub(lambda m: str(data.get(m.group(1), "")), template)
+def _stringify_template_value(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, list):
+ return "、".join(str(item) for item in value)
+ if isinstance(value, dict):
+ return "、".join(f"{key}: {val}" for key, val in value.items())
+ return str(value)
+
+
+def _template_data(payload: NotificationPayload) -> dict[str, Any]:
+ data = {"source_id": payload.source_id, **(payload.data or {})}
+ for key, value in (payload.ai_enrichment or {}).items():
+ rendered = _stringify_template_value(value)
+ data[key] = rendered
+ data[f"ai_{key}"] = rendered
+ return data
+
+
def _feishu_sign(secret: str, timestamp: int) -> str:
"""Generate Feishu webhook signature (加签)."""
string_to_sign = f"{timestamp}\n{secret}"
@@ -38,7 +57,12 @@ async def send(self, config: dict[str, Any], payload: NotificationPayload) -> bo
secret: str = config.get("secret", "")
title_template: str = config.get("title", "【新采集】{{title}}")
content_template: str = config.get(
- "content", "**来源**:{{source_id}}\n**标题**:{{title}}\n**链接**:{{url}}"
+ "content",
+ "**来源**:{{source_id}}\n"
+ "**标题**:{{title}}\n"
+ "**摘要**:{{summary}}\n"
+ "**标签**:{{tags}}\n"
+ "**链接**:{{url}}",
)
timeout: int = config.get("timeout", 15)
@@ -51,7 +75,7 @@ async def send(self, config: dict[str, Any], payload: NotificationPayload) -> bo
except SSRFValidationError:
return False
- data = {"source_id": payload.source_id, **(payload.data or {})}
+ data = _template_data(payload)
title = _render(title_template, data)
content = _render(content_template, data)
@@ -77,4 +101,4 @@ async def send(self, config: dict[str, Any], payload: NotificationPayload) -> bo
async with client as opened_client:
resp = await opened_client.post(webhook_url, json=body)
result = resp.json()
- return result.get("code", -1) == 0
+ return result.get("code", result.get("StatusCode", -1)) == 0
diff --git a/backend/pipeline/runner.py b/backend/pipeline/runner.py
index 5cb9114..18f3640 100644
--- a/backend/pipeline/runner.py
+++ b/backend/pipeline/runner.py
@@ -1,7 +1,7 @@
"""Shared pipeline runner — used by both local executor and Celery tasks."""
import logging
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from sqlalchemy import select
@@ -16,7 +16,8 @@
# Unfilled {{placeholders}} render to empty strings (see OpenAIProcessor._render).
DEFAULT_ENRICH_PROMPT = (
"分析下面这条采集记录, 只返回一个 JSON 对象, 字段: "
- '{"summary": "一句话摘要", "tags": ["关键词"], "category": "分类"}。\n'
+ '{"summary": "一句话摘要", "tags": ["关键词"], '
+ '"sentiment": "positive|neutral|negative", "category": "分类"}。\n'
"标题: {{title}}\n内容: {{content}}{{text}}{{description}}\n链接: {{url}}"
)
@@ -51,7 +52,7 @@ async def run_collection_pipeline(
status="running",
celery_task_id=celery_task_id,
worker_id=worker_id,
- started_at=datetime.now(timezone.utc),
+ started_at=datetime.now(UTC),
)
session.add(run)
task.status = "running"
@@ -132,7 +133,8 @@ async def run_collection_pipeline(
if provider.base_url:
cfg["base_url"] = provider.base_url
agent_config = {
- "processor_type": "openai", # OpenAI-compatible: covers Ollama/local/openai gateways
+ # OpenAI-compatible: covers Ollama/local/openai gateways.
+ "processor_type": "openai",
"model": provider.default_model,
"prompt_template": DEFAULT_ENRICH_PROMPT,
**cfg,
@@ -177,7 +179,7 @@ async def run_collection_pipeline(
if err_run:
err_run.status = "failed"
err_run.error_message = str(exc)
- err_run.finished_at = datetime.now(timezone.utc)
+ err_run.finished_at = datetime.now(UTC)
await session.commit()
raise
@@ -187,7 +189,7 @@ async def run_collection_pipeline(
run = await session.get(TaskRun, run_id)
if run:
- run.finished_at = datetime.now(timezone.utc)
+ run.finished_at = datetime.now(UTC)
run.duration_ms = pipeline_result.duration_ms
run.records_collected = pipeline_result.stored
if pipeline_result.metadata.get("node_url"):
@@ -312,7 +314,7 @@ async def run_scheduled_pipeline(
agent_id=schedule_agent_id,
)
if schedule:
- schedule.last_run_at = datetime.now(timezone.utc)
+ schedule.last_run_at = datetime.now(UTC)
is_one_time = schedule.is_one_time
await session.commit()
task_id = task.id
diff --git a/backend/ws_agent_manager.py b/backend/ws_agent_manager.py
index 5a8d4c5..d3eea92 100644
--- a/backend/ws_agent_manager.py
+++ b/backend/ws_agent_manager.py
@@ -27,9 +27,11 @@
"runtime": str, "workflow": str, "input": dict,
"config": dict, "session_id": str|None}
agent_event agent→center {"type": "agent_event", "request_id": uuid,
- "event": dict} # one RuntimeEvent (base.py EVENT_TYPES); 0..N per task
+ "event": dict}
+ # one RuntimeEvent; 0..N per task
agent_result agent→center {"type": "agent_result", "request_id": uuid,
- "result": dict} # the terminal done/error RuntimeEvent; exactly 1 per task
+ "result": dict}
+ # terminal done/error RuntimeEvent; exactly 1
Protocol (collect/result path):
1. Agent connects to ws(s)://{center}/api/v1/browsers/agents/ws
@@ -51,7 +53,7 @@
import inspect
import logging
import uuid
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable
from typing import Any
from fastapi import WebSocket
@@ -157,7 +159,7 @@ async def dispatch_collect(
logger.debug("WS dispatch | agent=%s request_id=%s site=%s cmd=%s",
agent_url, request_id, site, command)
return await asyncio.wait_for(fut, timeout=timeout)
- except asyncio.TimeoutError:
+ except TimeoutError:
raise TimeoutError(f"WS agent {agent_url!r} did not respond in {timeout}s")
finally:
_pending.pop(request_id, None)
@@ -212,14 +214,17 @@ async def send_agent_task(
logger.debug("WS agent_task dispatch | agent=%s request_id=%s runtime=%s",
agent_url, request_id, task.get("runtime"))
return await asyncio.wait_for(fut, timeout=timeout)
- except asyncio.TimeoutError:
+ except TimeoutError:
raise TimeoutError(f"WS agent {agent_url!r} did not complete agent_task in {timeout}s")
finally:
_pending_agent_tasks.pop(request_id, None)
_agent_task_callbacks.pop(request_id, None)
-async def _invoke_on_event(on_event: Callable[[dict[str, Any]], Any], event: dict[str, Any]) -> None:
+async def _invoke_on_event(
+ on_event: Callable[[dict[str, Any]], Any],
+ event: dict[str, Any],
+) -> None:
"""Call *on_event*, awaiting it if it returned an awaitable (async callable)."""
result = on_event(event)
if inspect.isawaitable(result):
@@ -244,6 +249,9 @@ def resolve_agent_result(request_id: str, msg: dict[str, Any]) -> None:
"""Called from the WS receive loop when an agent sends the terminal 'agent_result' frame."""
fut = _pending_agent_tasks.get(request_id)
if fut is None or fut.done():
- logger.warning("WS: unexpected agent_result for request_id=%s (no waiting future)", request_id)
+ logger.warning(
+ "WS: unexpected agent_result for request_id=%s (no waiting future)",
+ request_id,
+ )
return
fut.set_result(msg.get("result", {}))
diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx
index 3b2f2b0..3b8edc6 100644
--- a/frontend/app/(app)/dashboard/page.tsx
+++ b/frontend/app/(app)/dashboard/page.tsx
@@ -1,9 +1,9 @@
'use client'
-import { Activity, ArrowDownToLine, CheckCircle2, Send, Server } from 'lucide-react'
+import { Activity, ArrowDownToLine, BellRing, BrainCircuit, CheckCircle2, Send, Server, Tags } from 'lucide-react'
-import { useDashboardActivity, useDashboardStats, useWorkers } from '@/lib/api/hooks'
-import type { WorkerNode } from '@/lib/api/types'
+import { useDashboardActivity, useDashboardStats, useOpinionMonitor, useWorkers } from '@/lib/api/hooks'
+import type { OpinionMonitor, WorkerNode } from '@/lib/api/types'
import {
useMonitorFeed,
type FailureItem,
@@ -11,7 +11,7 @@ import {
type ThroughputPoint,
type WorkerView,
} from '@/lib/demo/monitor'
-import { formatNumber } from '@/lib/format'
+import { formatNumber, formatRelative } from '@/lib/format'
import { FailureFeed, TaskStream } from '@/components/monitor/task-stream'
import { ThroughputChart } from '@/components/monitor/throughput-chart'
import { WorkerAllocation } from '@/components/monitor/worker-allocation'
@@ -45,6 +45,117 @@ function KpiCard({
)
}
+function OpinionMonitorPanel({
+ data,
+ isLoading,
+ isError,
+}: {
+ data?: OpinionMonitor
+ isLoading: boolean
+ isError: boolean
+}) {
+ const topTags = data?.tags.slice(0, 6) ?? []
+ const topSentiment = data?.sentiment.slice(0, 4) ?? []
+ const recent = data?.recent ?? []
+
+ return (
+
+
+
+
+
+ 舆情监控
+
+
采集、AI 打标、飞书推送的最近 7 天实况
+
+ {isError ? (
+ 未连接
+ ) : isLoading ? (
+ 同步中
+ ) : (
+
+
+ 真实数据
+
+ )}
+
+
+
+
+
+
+ {formatNumber(data?.summary.records ?? 0)} / {formatNumber(data?.summary.ai_processed ?? 0)}
+
+
+
+
+
+ 飞书发送
+
+
+ {formatNumber(data?.summary.feishu_sent ?? 0)}
+ 失败 {data?.summary.feishu_failed ?? 0}
+
+
+
+
+
+ 标签 / 情绪
+
+
+ {[...topTags, ...topSentiment].slice(0, 7).map((item) => (
+
+ {item.label} · {item.count}
+
+ ))}
+ {!topTags.length && !topSentiment.length ? 暂无 : null}
+
+
+
+
+
+ {recent.length === 0 ? (
+
暂无已采集舆情记录
+ ) : (
+
+ {recent.map((item) => (
+
+
+
+ {item.title}
+
+ 飞书 {item.notification_status === 'sent' ? '已发' : item.notification_status === 'failed' ? '失败' : '待发'}
+
+
+
+ {item.summary || item.source_name}
+
+
+ {item.tags.slice(0, 4).map((tag) => (
+
+ {tag}
+
+ ))}
+ {item.sentiment}
+
+
+
+
{item.source_name}
+
{formatRelative(item.created_at)}
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
/** Map backend recent runs into the shared stream shape. */
function runsToStream(
runs: Array<{
@@ -82,6 +193,7 @@ function runsToStream(
export default function DashboardPage() {
const stats = useDashboardStats()
const activity = useDashboardActivity()
+ const opinion = useOpinionMonitor()
const workersQuery = useWorkers()
const demoMode = stats.isError
@@ -221,6 +333,8 @@ export default function DashboardPage() {
))}
+
+
diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts
index ca6f427..49658b6 100644
--- a/frontend/lib/api/endpoints.ts
+++ b/frontend/lib/api/endpoints.ts
@@ -20,6 +20,7 @@ import type {
NotificationLog,
NotificationRule,
OdpSystemState,
+ OpinionMonitor,
PlanGraph,
PlanHealthRead,
PlanRead,
@@ -41,6 +42,13 @@ export const getDashboardStats = (params?: { range?: string; start?: string; end
export const getDashboardActivity = (params?: { days?: number; tz_offset?: number }) =>
apiClient.get
>('/dashboard/activity', { params }).then((r) => r.data.data)
+export const getOpinionMonitor = (params?: {
+ range?: string
+ start?: string
+ end?: string
+ limit?: number
+}) => apiClient.get>('/dashboard/opinion-monitor', { params }).then((r) => r.data.data)
+
// ── Sources ────────────────────────────────────────────────────────────────────
export const listSources = (params?: { page?: number; limit?: number; enabled?: boolean }) =>
apiClient.get>('/sources', { params }).then((r) => r.data)
diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts
index e3216ce..a145da9 100644
--- a/frontend/lib/api/hooks.ts
+++ b/frontend/lib/api/hooks.ts
@@ -19,6 +19,14 @@ export function useDashboardActivity(days = 14) {
})
}
+export function useOpinionMonitor() {
+ return useQuery({
+ queryKey: ['dashboard', 'opinion-monitor'],
+ queryFn: () => api.getOpinionMonitor({ range: '7d', limit: 8 }),
+ refetchInterval: 30_000,
+ })
+}
+
export function useSources(params?: { page?: number; limit?: number; enabled?: boolean }) {
return useQuery({
queryKey: ['sources', params],
diff --git a/frontend/lib/api/types.ts b/frontend/lib/api/types.ts
index ca78624..a257088 100644
--- a/frontend/lib/api/types.ts
+++ b/frontend/lib/api/types.ts
@@ -283,6 +283,44 @@ export interface DashboardActivity {
daily: DailyActivity[]
}
+export interface OpinionMonitorRecord {
+ id: string
+ source_id: string
+ source_name: string
+ title: string
+ url?: string | null
+ summary: string
+ tags: string[]
+ sentiment: string
+ status: string
+ notification_status: 'sent' | 'failed' | 'pending'
+ created_at: string
+}
+
+export interface OpinionMonitor {
+ window: { range: string; since?: string | null; until?: string | null }
+ summary: {
+ records: number
+ ai_processed: number
+ feishu_sent: number
+ feishu_failed: number
+ active_sources: number
+ active_feishu_rules: number
+ }
+ tags: Array<{ label: string; count: number }>
+ sentiment: Array<{ label: string; count: number }>
+ sources: Array<{
+ id: string
+ name: string
+ channel_type: string
+ records: number
+ ai_processed: number
+ feishu_sent: number
+ feishu_failed: number
+ }>
+ recent: OpinionMonitorRecord[]
+}
+
// ── Control-state (C0 Control Room v0 — docs/CONTROL_THEORY_ARCHITECTURE.md §0) ─
// Read-only sensor-honesty view of a source: GET /sources/{id}/control-state.
// `measurement`/`control_state`/`confidence`/`sensor_coverage` are all null when
diff --git a/scripts/acceptance/fleet-acceptance.ps1 b/scripts/acceptance/fleet-acceptance.ps1
new file mode 100644
index 0000000..c75bd7e
--- /dev/null
+++ b/scripts/acceptance/fleet-acceptance.ps1
@@ -0,0 +1,775 @@
+#requires -Version 5.1
+[CmdletBinding()]
+param(
+ [string]$Site = "aibase",
+ [string]$Command = "news",
+ [int]$Limit = 1,
+ [int]$CenterPort = 8032,
+ [int]$AgentPort = 19824,
+ [switch]$FreshDb,
+ [switch]$SkipCodeIntel,
+ [int]$DurationThresholdSeconds = 30,
+ [int]$CollectTimeoutSeconds = 75,
+ [int]$RegressionTimeoutSeconds = 300
+)
+
+$ErrorActionPreference = "Stop"
+
+$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
+$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
+$ArtifactDir = Join-Path $RepoRoot "artifacts\acceptance\fleet\$Timestamp"
+$ApiOutLog = Join-Path $ArtifactDir "api.stdout.log"
+$ApiErrLog = Join-Path $ArtifactDir "api.stderr.log"
+$AgentOutLog = Join-Path $ArtifactDir "agent.stdout.log"
+$AgentErrLog = Join-Path $ArtifactDir "agent.stderr.log"
+$ApiLog = Join-Path $ArtifactDir "api.log"
+$AgentLog = Join-Path $ArtifactDir "agent.log"
+$DbFile = Join-Path $ArtifactDir "fleet-acceptance.sqlite"
+$AgentEndpoint = "http://127.0.0.1:$AgentPort"
+$CenterUrl = "http://127.0.0.1:$CenterPort"
+$BaseApiUrl = "$CenterUrl/api/v1"
+$StartedProcesses = New-Object System.Collections.Generic.List[System.Diagnostics.Process]
+$script:Failure = $null
+
+New-Item -ItemType Directory -Force -Path $ArtifactDir | Out-Null
+
+function Get-PythonExe {
+ $venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
+ if (Test-Path $venvPython) {
+ return (Resolve-Path $venvPython).Path
+ }
+ return "python"
+}
+
+$PythonExe = Get-PythonExe
+
+function Get-PowerShellCoreExe {
+ $cmd = Get-Command pwsh -ErrorAction SilentlyContinue
+ if ($cmd) {
+ return $cmd.Source
+ }
+ return "powershell"
+}
+
+$PwshExe = Get-PowerShellCoreExe
+
+function Invoke-TextCommand {
+ param(
+ [string]$FilePath,
+ [string[]]$Arguments = @()
+ )
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
+ $psi.FileName = $FilePath
+ $psi.WorkingDirectory = $RepoRoot
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.CreateNoWindow = $true
+ foreach ($arg in $Arguments) {
+ [void]$psi.ArgumentList.Add($arg)
+ }
+ $proc = [System.Diagnostics.Process]::new()
+ $proc.StartInfo = $psi
+ [void]$proc.Start()
+ $stdout = $proc.StandardOutput.ReadToEnd()
+ $stderr = $proc.StandardError.ReadToEnd()
+ $proc.WaitForExit()
+ return [ordered]@{
+ exitCode = $proc.ExitCode
+ stdout = $stdout
+ stderr = $stderr
+ text = ($stdout + "`n" + $stderr).Trim()
+ }
+}
+
+function Save-Json {
+ param(
+ [string]$Name,
+ $Value
+ )
+ $path = Join-Path $ArtifactDir $Name
+ $Value | ConvertTo-Json -Depth 80 | Set-Content -LiteralPath $path -Encoding UTF8
+ return $path
+}
+
+function Set-Gate {
+ param(
+ [string]$Name,
+ [string]$Status,
+ [hashtable]$Details = @{}
+ )
+ $script:Report.gates[$Name] = [ordered]@{
+ status = $Status
+ details = $Details
+ }
+}
+
+function Fail-Gate {
+ param(
+ [string]$Gate,
+ [string]$Message,
+ $Expected = $null,
+ $Actual = $null
+ )
+ Set-Gate $Gate "fail" @{
+ message = $Message
+ expected = $Expected
+ actual = $Actual
+ }
+ $script:Failure = [ordered]@{
+ gate = $Gate
+ message = $Message
+ expected = $Expected
+ actual = $Actual
+ }
+ throw "ACCEPTANCE_FAIL:${Gate}:$Message"
+}
+
+function Assert-Gate {
+ param(
+ [string]$Gate,
+ [bool]$Condition,
+ [string]$Message,
+ $Expected = $null,
+ $Actual = $null
+ )
+ if (-not $Condition) {
+ Fail-Gate $Gate $Message $Expected $Actual
+ }
+}
+
+function Test-PortBusy {
+ param([int]$Port)
+ return [bool](Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
+}
+
+function Get-PortOwners {
+ param([int]$Port)
+ $owners = @()
+ foreach ($conn in @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)) {
+ $proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
+ $cmd = Get-CimInstance Win32_Process -Filter "ProcessId=$($conn.OwningProcess)" -ErrorAction SilentlyContinue
+ $owners += [ordered]@{
+ port = $Port
+ pid = $conn.OwningProcess
+ process = $proc.ProcessName
+ path = $proc.Path
+ commandLine = $cmd.CommandLine
+ }
+ }
+ return $owners
+}
+
+function Start-ManagedProcess {
+ param(
+ [string]$Name,
+ [string]$FilePath,
+ [string[]]$Arguments,
+ [string]$StdoutPath,
+ [string]$StderrPath
+ )
+ "" | Set-Content -LiteralPath $StdoutPath -Encoding UTF8
+ "" | Set-Content -LiteralPath $StderrPath -Encoding UTF8
+ $proc = Start-Process `
+ -FilePath $FilePath `
+ -ArgumentList $Arguments `
+ -WorkingDirectory $RepoRoot `
+ -RedirectStandardOutput $StdoutPath `
+ -RedirectStandardError $StderrPath `
+ -PassThru `
+ -WindowStyle Hidden
+ $StartedProcesses.Add($proc) | Out-Null
+ $script:Report.processes[$Name] = [ordered]@{
+ pid = $proc.Id
+ command = "$FilePath $($Arguments -join ' ')"
+ }
+ return $proc
+}
+
+function Stop-ManagedProcesses {
+ for ($i = $StartedProcesses.Count - 1; $i -ge 0; $i--) {
+ $proc = $StartedProcesses[$i]
+ try {
+ if ($proc -and -not $proc.HasExited) {
+ Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
+ $proc.WaitForExit(5000) | Out-Null
+ }
+ } catch {
+ # Best effort cleanup only.
+ }
+ }
+}
+
+function Merge-Log {
+ param(
+ [string]$OutPath,
+ [string]$ErrPath,
+ [string]$TargetPath
+ )
+ $parts = @()
+ if (Test-Path $OutPath) {
+ $parts += "### stdout"
+ $parts += (Get-Content -LiteralPath $OutPath -Raw -ErrorAction SilentlyContinue)
+ }
+ if (Test-Path $ErrPath) {
+ $parts += "### stderr"
+ $parts += (Get-Content -LiteralPath $ErrPath -Raw -ErrorAction SilentlyContinue)
+ }
+ $parts -join "`n" | Set-Content -LiteralPath $TargetPath -Encoding UTF8
+}
+
+function Invoke-Api {
+ param(
+ [ValidateSet("GET", "POST", "PATCH", "DELETE")]
+ [string]$Method,
+ [string]$Path,
+ $Body = $null,
+ [int]$TimeoutSec = 15
+ )
+ $uri = "$BaseApiUrl$Path"
+ if ($null -ne $Body) {
+ $json = $Body | ConvertTo-Json -Depth 80
+ return Invoke-RestMethod -Method $Method -Uri $uri -Body $json -ContentType "application/json" -TimeoutSec $TimeoutSec
+ }
+ return Invoke-RestMethod -Method $Method -Uri $uri -TimeoutSec $TimeoutSec
+}
+
+function Wait-Until {
+ param(
+ [string]$Gate,
+ [int]$TimeoutSeconds,
+ [scriptblock]$Probe
+ )
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $lastError = $null
+ while ((Get-Date) -lt $deadline) {
+ try {
+ $result = & $Probe
+ if ($result) {
+ return $result
+ }
+ } catch {
+ $lastError = $_.Exception.Message
+ }
+ Start-Sleep -Milliseconds 500
+ }
+ Fail-Gate $Gate "Timed out waiting for $Gate" "ready within ${TimeoutSeconds}s" $lastError
+}
+
+function Invoke-LoggedProcess {
+ param(
+ [string]$Label,
+ [string]$FilePath,
+ [string[]]$Arguments,
+ [int]$TimeoutSeconds = 300
+ )
+ $logPath = Join-Path $ArtifactDir "$Label.log"
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
+ $psi.FileName = $FilePath
+ $psi.WorkingDirectory = $RepoRoot
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.CreateNoWindow = $true
+ foreach ($arg in $Arguments) {
+ [void]$psi.ArgumentList.Add($arg)
+ }
+
+ $proc = [System.Diagnostics.Process]::new()
+ $proc.StartInfo = $psi
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
+ [void]$proc.Start()
+ $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
+ $stderrTask = $proc.StandardError.ReadToEndAsync()
+ $timedOut = -not $proc.WaitForExit($TimeoutSeconds * 1000)
+ if ($timedOut) {
+ try { $proc.Kill() } catch {}
+ try { $proc.WaitForExit(5000) | Out-Null } catch {}
+ }
+ $sw.Stop()
+ $stdout = $stdoutTask.Result
+ $stderr = $stderrTask.Result
+ $text = ($stdout + "`n" + $stderr).Trim()
+ $text | Set-Content -LiteralPath $logPath -Encoding UTF8
+ $exitCode = if ($timedOut) { -1 } else { $proc.ExitCode }
+ return [ordered]@{
+ label = $Label
+ command = "$FilePath $($Arguments -join ' ')"
+ exitCode = $exitCode
+ timedOut = $timedOut
+ durationMs = $sw.ElapsedMilliseconds
+ log = $logPath
+ textPreview = if ($text.Length -gt 2000) { $text.Substring(0, 2000) } else { $text }
+ }
+}
+
+function Get-RegressionEvidenceText {
+ param([string]$LogPath)
+
+ if (-not (Test-Path -LiteralPath $LogPath)) {
+ return ""
+ }
+
+ $raw = Get-Content -LiteralPath $LogPath -Raw -ErrorAction SilentlyContinue
+ $chunks = New-Object System.Collections.Generic.List[string]
+ $chunks.Add($raw)
+
+ $paths = New-Object System.Collections.Generic.List[string]
+ foreach ($match in [regex]::Matches($raw, "[A-Za-z]:\\[^\r\n""]+?(?:summary\.md|hospital\.md|understanding\.md|report\.json)")) {
+ $paths.Add($match.Value.Trim())
+ }
+
+ foreach ($path in @($paths)) {
+ if (-not (Test-Path -LiteralPath $path)) {
+ continue
+ }
+ $dir = (Get-Item -LiteralPath $path).DirectoryName
+ foreach ($name in @("summary.md", "hospital.md", "understanding.md", "sentrux-debt-register.json")) {
+ $paths.Add((Join-Path $dir $name))
+ }
+ }
+
+ $seen = @{}
+ foreach ($path in $paths) {
+ if ($seen.ContainsKey($path) -or -not (Test-Path -LiteralPath $path)) {
+ continue
+ }
+ $seen[$path] = $true
+ $chunks.Add("`n--- $path ---`n")
+ $chunks.Add((Get-Content -LiteralPath $path -Raw -ErrorAction SilentlyContinue))
+ }
+
+ return ($chunks -join "`n")
+}
+
+function Run-RegressionGate {
+ param(
+ [string]$Gate,
+ [string]$Label,
+ [string]$FilePath,
+ [string[]]$Arguments,
+ [int]$TimeoutSeconds = 300,
+ [bool]$Hard = $true,
+ [string]$KnownDebtRegex = ""
+ )
+ $result = Invoke-LoggedProcess -Label $Label -FilePath $FilePath -Arguments $Arguments -TimeoutSeconds $TimeoutSeconds
+ $script:Report.regression[$Label] = $result
+ if ($result.exitCode -eq 0 -and -not $result.timedOut) {
+ Set-Gate $Gate "pass" @{ log = $result.log; durationMs = $result.durationMs }
+ return $result
+ }
+ $text = Get-RegressionEvidenceText -LogPath $result.log
+ if ($KnownDebtRegex -and $text -match $KnownDebtRegex) {
+ Set-Gate $Gate "known_debt" @{
+ log = $result.log
+ exitCode = $result.exitCode
+ timedOut = $result.timedOut
+ matched = $Matches[0]
+ }
+ return $result
+ }
+ if ($Hard) {
+ Fail-Gate $Gate "Regression command failed" "exitCode=0" @{
+ exitCode = $result.exitCode
+ timedOut = $result.timedOut
+ log = $result.log
+ }
+ }
+ Set-Gate $Gate "warn" @{ log = $result.log; exitCode = $result.exitCode; timedOut = $result.timedOut }
+ return $result
+}
+
+function Read-LogText {
+ $texts = @()
+ foreach ($path in @($ApiOutLog, $ApiErrLog, $ApiLog)) {
+ if (Test-Path $path) {
+ $texts += Get-Content -LiteralPath $path -Raw -ErrorAction SilentlyContinue
+ }
+ }
+ return ($texts -join "`n")
+}
+
+function Write-ReportFiles {
+ $script:Report.finishedAt = (Get-Date).ToString("o")
+ if ($script:Failure) {
+ $script:Report.acceptance = "FAIL"
+ $script:Report.failure = $script:Failure
+ }
+ Save-Json "report.json" $script:Report | Out-Null
+
+ $lines = @()
+ $lines += "# Fleet Acceptance Report"
+ $lines += ""
+ $lines += "- acceptance: $($script:Report.acceptance)"
+ $lines += "- repo: $RepoRoot"
+ $lines += "- branch: $($script:Report.git.branch)"
+ $lines += "- commit: $($script:Report.git.commit)"
+ $lines += "- python: $($script:Report.python.version)"
+ $lines += "- center: $CenterUrl"
+ $lines += "- agent: $AgentEndpoint"
+ $lines += "- database: $DbFile"
+ $lines += ""
+ $lines += "## Gates"
+ foreach ($key in $script:Report.gates.Keys) {
+ $lines += "- ${key}: $($script:Report.gates[$key].status)"
+ }
+ if ($script:Failure) {
+ $lines += ""
+ $lines += "## Failure"
+ $lines += "- gate: $($script:Failure.gate)"
+ $lines += "- message: $($script:Failure.message)"
+ $lines += "- expected: $($script:Failure.expected | ConvertTo-Json -Compress -Depth 20)"
+ $lines += "- actual: $($script:Failure.actual | ConvertTo-Json -Compress -Depth 20)"
+ }
+ $lines -join "`n" | Set-Content -LiteralPath (Join-Path $ArtifactDir "report.md") -Encoding UTF8
+}
+
+$gitCommit = (Invoke-TextCommand -FilePath "git" -Arguments @("rev-parse", "HEAD")).stdout.Trim()
+$gitBranch = (Invoke-TextCommand -FilePath "git" -Arguments @("branch", "--show-current")).stdout.Trim()
+$gitStatus = (Invoke-TextCommand -FilePath "git" -Arguments @("status", "--short", "--branch")).stdout.Trim()
+$pythonVersionResult = Invoke-TextCommand -FilePath $PythonExe -Arguments @("--version")
+$pythonVersion = (($pythonVersionResult.stdout + $pythonVersionResult.stderr).Trim())
+
+$script:Report = [ordered]@{
+ acceptance = "RUNNING"
+ startedAt = (Get-Date).ToString("o")
+ finishedAt = $null
+ repo = $RepoRoot
+ artifacts = [ordered]@{
+ root = $ArtifactDir
+ reportJson = (Join-Path $ArtifactDir "report.json")
+ reportMd = (Join-Path $ArtifactDir "report.md")
+ apiLog = $ApiLog
+ agentLog = $AgentLog
+ inventory = (Join-Path $ArtifactDir "inventory.json")
+ match = (Join-Path $ArtifactDir "match.json")
+ runEvents = (Join-Path $ArtifactDir "run-events.json")
+ }
+ git = [ordered]@{
+ branch = $gitBranch
+ commit = $gitCommit
+ status = $gitStatus
+ }
+ python = [ordered]@{
+ executable = $PythonExe
+ version = $pythonVersion
+ }
+ powershell = [ordered]@{
+ executable = $PwshExe
+ }
+ params = [ordered]@{
+ site = $Site
+ command = $Command
+ limit = $Limit
+ centerPort = $CenterPort
+ agentPort = $AgentPort
+ freshDb = [bool]$FreshDb
+ durationThresholdSeconds = $DurationThresholdSeconds
+ collectTimeoutSeconds = $CollectTimeoutSeconds
+ skipCodeIntel = [bool]$SkipCodeIntel
+ }
+ environment = [ordered]@{
+ databaseFile = $DbFile
+ databaseUrl = "sqlite+aiosqlite:///$($DbFile.Replace('\', '/'))"
+ centerUrl = $CenterUrl
+ agentEndpoint = $AgentEndpoint
+ }
+ processes = [ordered]@{}
+ gates = [ordered]@{}
+ evidence = [ordered]@{}
+ regression = [ordered]@{}
+ failure = $null
+}
+
+try {
+ foreach ($port in @($CenterPort, $AgentPort)) {
+ if (Test-PortBusy $port) {
+ $owners = Get-PortOwners $port
+ Save-Json "port-$port-owners.json" $owners | Out-Null
+ Fail-Gate "environment" "Port $port is already in use" "free port" $owners
+ }
+ }
+
+ if ($FreshDb) {
+ foreach ($path in @($DbFile, "$DbFile-wal", "$DbFile-shm")) {
+ if (Test-Path $path) {
+ Remove-Item -LiteralPath $path -Force
+ }
+ }
+ }
+ Set-Gate "environment" "pass" @{
+ commit = $gitCommit
+ branch = $gitBranch
+ python = $pythonVersion
+ centerPort = $CenterPort
+ agentPort = $AgentPort
+ databaseFile = $DbFile
+ }
+
+ $dbUrl = $script:Report.environment.databaseUrl
+
+ $env:DATABASE_URL = $dbUrl
+ $env:TASK_EXECUTOR = "local"
+ $env:COLLECTION_MODE = "agent"
+ $env:API_AUTH_TOKEN = ""
+ $env:AGENT_POOL_ENDPOINTS = ""
+ $env:OPENCLI_TIMEOUT = [string][Math]::Max($DurationThresholdSeconds, 30)
+ $env:AGENT_WS_TIMEOUT = [string][Math]::Max($CollectTimeoutSeconds, 45)
+ $env:AGENT_HTTP_TIMEOUT = [string][Math]::Max($CollectTimeoutSeconds, 45)
+
+ $apiArgs = @("-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", [string]$CenterPort)
+ Start-ManagedProcess -Name "center-api" -FilePath $PythonExe -Arguments $apiArgs -StdoutPath $ApiOutLog -StderrPath $ApiErrLog | Out-Null
+
+ Wait-Until -Gate "health" -TimeoutSeconds 45 -Probe {
+ $health = Invoke-RestMethod -Method GET -Uri "$CenterUrl/health" -TimeoutSec 3
+ if ($health.status -eq "ok") { return $health }
+ return $false
+ } | Out-Null
+
+ $env:CENTRAL_API_URL = $CenterUrl
+ $env:AGENT_REGISTER = "ws"
+ $env:AGENT_PORT = [string]$AgentPort
+ $env:AGENT_ADVERTISE_URL = $AgentEndpoint
+ $env:AGENT_LABEL = "fleet-acceptance-$Timestamp"
+ $env:AGENT_MODE = "bridge"
+ $env:AGENT_DEPLOY_TYPE = "shell"
+ $env:OPENCLI_BIN = "opencli"
+
+ $agentArgs = @("-m", "uvicorn", "backend.agent_server:app", "--host", "127.0.0.1", "--port", [string]$AgentPort)
+ Start-ManagedProcess -Name "ws-agent" -FilePath $PythonExe -Arguments $agentArgs -StdoutPath $AgentOutLog -StderrPath $AgentErrLog | Out-Null
+
+ Wait-Until -Gate "agent-health" -TimeoutSeconds 30 -Probe {
+ $health = Invoke-RestMethod -Method GET -Uri "$AgentEndpoint/health" -TimeoutSec 3
+ if ($health.status -eq "ok" -and $health.opencli_bin_exists) { return $health }
+ return $false
+ } | Out-Null
+
+ $nodes = Wait-Until -Gate "cluster-start" -TimeoutSeconds 45 -Probe {
+ $response = Invoke-Api -Method GET -Path "/nodes" -TimeoutSec 5
+ $node = @($response.data) | Where-Object { $_.url -eq $AgentEndpoint } | Select-Object -First 1
+ if (-not $node) { return $false }
+ $runtimes = @($node.runtimes)
+ if ($node.status -eq "online" -and $node.protocol -eq "ws" -and
+ $runtimes -contains "miniflow" -and $runtimes -contains "opentabs") {
+ return $response
+ }
+ return $false
+ }
+ Save-Json "nodes.json" $nodes.data | Out-Null
+ Set-Gate "cluster-start" "pass" @{
+ endpoint = $AgentEndpoint
+ protocol = "ws"
+ runtimes = @("miniflow", "opentabs")
+ }
+
+ $inventoryResp = Invoke-Api -Method GET -Path "/workflows/fleet/inventory"
+ $inventory = $inventoryResp.data
+ Save-Json "inventory.json" $inventory | Out-Null
+ $agentInventory = @($inventory.agents) | Where-Object { $_.endpoint -eq $AgentEndpoint } | Select-Object -First 1
+ Assert-Gate "capability-snapshot" ($inventory.version -eq "1.1.0") "inventory version mismatch" "1.1.0" $inventory.version
+ Assert-Gate "capability-snapshot" ($inventory.summary.clusterModel -eq "private-agent-pod") "cluster model mismatch" "private-agent-pod" $inventory.summary.clusterModel
+ Assert-Gate "capability-snapshot" ($inventory.summary.routingPolicy -eq "site_binding_agent_first") "routing policy mismatch" "site_binding_agent_first" $inventory.summary.routingPolicy
+ Assert-Gate "capability-snapshot" ([int]$inventory.summary.wsConnected -ge 1) "no WS agent connected" "wsConnected >= 1" $inventory.summary.wsConnected
+ Assert-Gate "capability-snapshot" ($null -ne $agentInventory) "agent missing from inventory" $AgentEndpoint $null
+ Assert-Gate "capability-snapshot" ((@($agentInventory.runtimes) -contains "miniflow") -and (@($agentInventory.runtimes) -contains "opentabs")) "agent runtime inventory missing miniflow/opentabs" "miniflow,opentabs" $agentInventory.runtimes
+ Set-Gate "capability-snapshot" "pass" @{
+ version = $inventory.version
+ wsConnected = $inventory.summary.wsConnected
+ runtimes = $agentInventory.runtimes
+ }
+
+ $bindingBody = @{
+ browser_endpoint = $AgentEndpoint
+ site = $Site
+ notes = "fleet acceptance $Timestamp"
+ }
+ $bindingResp = Invoke-Api -Method POST -Path "/browsers/bindings" -Body $bindingBody
+ Save-Json "binding.json" $bindingResp.data | Out-Null
+
+ $matchBody = @{
+ site = $Site
+ command = $Command
+ }
+ $matchResp = Invoke-Api -Method POST -Path "/workflows/fleet/match" -Body $matchBody
+ $match = $matchResp.data
+ Save-Json "match.json" $match | Out-Null
+ $selectedReasons = @($match.selected.reasons)
+ Assert-Gate "binding-match" ($match.matched -eq $true) "fleet match did not match" $true $match.matched
+ Assert-Gate "binding-match" ($match.selected.endpoint -eq $AgentEndpoint) "selected endpoint mismatch" $AgentEndpoint $match.selected.endpoint
+ Assert-Gate "binding-match" (@($match.missing).Count -eq 0) "fleet match has missing requirements" "[]" $match.missing
+ Assert-Gate "binding-match" (@($match.selected.missing).Count -eq 0) "selected endpoint has missing requirements" "[]" $match.selected.missing
+ Assert-Gate "binding-match" ($selectedReasons -contains "site_binding") "site_binding reason missing" "site_binding" $selectedReasons
+ Assert-Gate "binding-match" ($selectedReasons -contains "reverse_ws_agent") "reverse_ws_agent reason missing" "reverse_ws_agent" $selectedReasons
+ Set-Gate "binding-match" "pass" @{
+ selectedEndpoint = $match.selected.endpoint
+ reasons = $selectedReasons
+ }
+
+ $sourceBody = @{
+ name = "fleet acceptance $Site $Command $Timestamp"
+ description = "Acceptance source generated by scripts/acceptance/fleet-acceptance.ps1"
+ channel_type = "opencli"
+ channel_config = @{
+ site = $Site
+ command = $Command
+ format = "json"
+ args = @{}
+ }
+ ai_config = $null
+ enabled = $true
+ tags = @("acceptance", "fleet")
+ }
+ $sourceResp = Invoke-Api -Method POST -Path "/sources" -Body $sourceBody
+ $source = $sourceResp.data
+ Save-Json "source.json" $source | Out-Null
+ $sourceId = $source.id
+
+ $triggerBody = @{
+ source_id = $sourceId
+ parameters = @{
+ limit = $Limit
+ }
+ priority = 5
+ }
+ $triggerResp = Invoke-Api -Method POST -Path "/tasks/trigger" -Body $triggerBody
+ Save-Json "trigger.json" $triggerResp.data | Out-Null
+ $taskId = $triggerResp.data.task_id
+ Assert-Gate "real-collect" (-not [string]::IsNullOrWhiteSpace($taskId)) "trigger response missing task_id" "task_id" $triggerResp.data
+
+ $runState = Wait-Until -Gate "real-collect" -TimeoutSeconds $CollectTimeoutSeconds -Probe {
+ $taskResp = Invoke-Api -Method GET -Path "/tasks/$taskId" -TimeoutSec 5
+ $runsResp = Invoke-Api -Method GET -Path "/tasks/$taskId/runs?limit=5" -TimeoutSec 5
+ $runs = @($runsResp.data)
+ $run = $runs | Select-Object -First 1
+ if ($taskResp.data.status -eq "failed") {
+ Fail-Gate "real-collect" "task failed" "completed" $taskResp.data
+ }
+ if ($run -and $run.status -eq "failed") {
+ Fail-Gate "real-collect" "run failed" "completed" $run
+ }
+ if ($run -and $taskResp.data.status -eq "completed" -and $run.status -eq "completed") {
+ return [ordered]@{ task = $taskResp.data; run = $run; runs = $runsResp.data }
+ }
+ return $false
+ }
+ Save-Json "task.json" $runState.task | Out-Null
+ Save-Json "runs.json" $runState.runs | Out-Null
+ $run = $runState.run
+ $runId = $run.id
+ Assert-Gate "real-collect" ([int]$run.records_collected -ge 1) "no records collected" "records_collected >= 1" $run.records_collected
+ Assert-Gate "real-collect" ([int]$run.duration_ms -lt ($DurationThresholdSeconds * 1000)) "run duration exceeded threshold" "< $($DurationThresholdSeconds * 1000) ms" $run.duration_ms
+ Set-Gate "real-collect" "pass" @{
+ taskId = $taskId
+ runId = $runId
+ recordsCollected = $run.records_collected
+ durationMs = $run.duration_ms
+ }
+
+ $eventsResp = Invoke-Api -Method GET -Path "/tasks/$taskId/runs/$runId/events"
+ $events = @($eventsResp.data)
+ Save-Json "run-events.json" $events | Out-Null
+ $collectStart = $events | Where-Object { $_.step -eq "collect" -and $_.detail -and $_.detail.params -and $_.detail.params.chrome_endpoint } | Select-Object -First 1
+ $collectDone = $events | Where-Object { $_.step -eq "collect" -and $_.detail -and $_.detail.metadata -and $_.detail.metadata.node_url } | Select-Object -First 1
+ $paramsEndpoint = if ($collectStart) { $collectStart.detail.params.chrome_endpoint } else { $null }
+ $metadataNodeUrl = if ($collectDone) { $collectDone.detail.metadata.node_url } else { $null }
+ Start-Sleep -Milliseconds 500
+ $apiLogText = Read-LogText
+ Assert-Gate "route-proof" ($paramsEndpoint -eq $AgentEndpoint) "collect params chrome_endpoint mismatch" $AgentEndpoint $paramsEndpoint
+ Assert-Gate "route-proof" ($metadataNodeUrl -eq $AgentEndpoint) "collect metadata node_url mismatch" $AgentEndpoint $metadataNodeUrl
+ Assert-Gate "route-proof" ($apiLogText -match "WS agent dispatch") "center log missing WS agent dispatch" "WS agent dispatch" "not found"
+ Assert-Gate "route-proof" ($apiLogText -match "WS agent done") "center log missing WS agent done" "WS agent done" "not found"
+ Set-Gate "route-proof" "pass" @{
+ chromeEndpoint = $paramsEndpoint
+ nodeUrl = $metadataNodeUrl
+ logEvidence = @("WS agent dispatch", "WS agent done")
+ }
+
+ $recordsResp = Invoke-Api -Method GET -Path "/records?source_id=$sourceId&task_id=$taskId&limit=100"
+ $records = @($recordsResp.data)
+ Save-Json "records.json" $records | Out-Null
+ Assert-Gate "data-proof" ($records.Count -ge 1) "records API returned no records" "count >= 1" $records.Count
+ $badRecord = $records | Where-Object {
+ $_.source_id -ne $sourceId -or
+ $_.task_id -ne $taskId -or
+ $_.status -ne "normalized" -or
+ $null -ne $_.error_message
+ } | Select-Object -First 1
+ Assert-Gate "data-proof" ($null -eq $badRecord) "record data proof failed" "source_id/task_id/status=normalized/error_message=null" $badRecord
+ Set-Gate "data-proof" "pass" @{
+ recordCount = $records.Count
+ sourceId = $sourceId
+ taskId = $taskId
+ status = "normalized"
+ }
+
+ $ruffScope = @(
+ "backend/workflow/fleet_inventory.py",
+ "backend/channels/opencli_channel.py",
+ "backend/ws_agent_manager.py",
+ "backend/api/v1/workflows.py",
+ "backend/api/v1/nodes.py",
+ "backend/agent_server.py",
+ "tests/integration/test_workflow_fleet_api.py",
+ "tests/integration/test_opencli_channel_api.py",
+ "tests/unit/channels/test_opencli_channel.py"
+ )
+ Run-RegressionGate -Gate "regression-ruff" -Label "ruff-check" -FilePath $PythonExe -Arguments (@("-m", "ruff", "check") + $ruffScope) -TimeoutSeconds $RegressionTimeoutSeconds -Hard $true | Out-Null
+ Run-RegressionGate -Gate "regression-pytest" -Label "pytest-fleet-opencli" -FilePath $PythonExe -Arguments @(
+ "-m", "pytest", "-q", "--no-cov",
+ "tests/integration/test_workflow_fleet_api.py",
+ "tests/integration/test_opencli_channel_api.py::test_collect_agent_mode_prefers_site_bound_agent",
+ "tests/unit/channels/test_opencli_channel.py"
+ ) -TimeoutSeconds $RegressionTimeoutSeconds -Hard $true | Out-Null
+ Run-RegressionGate -Gate "regression-sentrux" -Label "sentrux-check-rules" -FilePath $PwshExe -Arguments @(
+ "-NoProfile", "-ExecutionPolicy", "Bypass",
+ "-File", "C:\c\Users\Administrator\projects\code-intel-pipeline\Invoke-SentruxAgentTool.ps1",
+ "check_rules", $RepoRoot
+ ) -TimeoutSeconds $RegressionTimeoutSeconds -Hard $true | Out-Null
+
+ if (-not $SkipCodeIntel) {
+ Run-RegressionGate -Gate "regression-code-intel-doctor" -Label "code-intel-doctor" -FilePath $PwshExe -Arguments @(
+ "-NoProfile", "-ExecutionPolicy", "Bypass",
+ "-File", "C:\c\Users\Administrator\projects\code-intel-pipeline\check-code-intel-tools.ps1",
+ "-RepoPath", $RepoRoot,
+ "-RequireRepowise",
+ "-Json"
+ ) -TimeoutSeconds $RegressionTimeoutSeconds -Hard $true | Out-Null
+
+ Run-RegressionGate -Gate "regression-code-intel-normal" -Label "code-intel-normal" -FilePath $PwshExe -Arguments @(
+ "-NoProfile", "-ExecutionPolicy", "Bypass",
+ "-File", "C:\c\Users\Administrator\projects\code-intel-pipeline\invoke-code-intel.ps1",
+ "-RepoPath", $RepoRoot,
+ "-Mode", "normal"
+ ) -TimeoutSeconds $RegressionTimeoutSeconds -Hard $true -KnownDebtRegex "graph_missing|Understand graph: False|baseline_missing|rules_missing|known debt|known_debt|Sentrux fail|sentrux_fail|Sentrux gate|Blocking Sentrux debt|worsened_debt|god_files|Quality degraded during this session" | Out-Null
+ } else {
+ Set-Gate "regression-code-intel" "skipped" @{ reason = "SkipCodeIntel was set" }
+ }
+
+ $script:Report.acceptance = "PASS"
+} catch {
+ if (-not $script:Failure) {
+ $script:Failure = [ordered]@{
+ gate = "script-error"
+ message = $_.Exception.Message
+ expected = "no exception"
+ actual = $_.ToString()
+ }
+ Set-Gate "script-error" "fail" @{ message = $_.Exception.Message }
+ }
+} finally {
+ Stop-ManagedProcesses
+ Merge-Log -OutPath $ApiOutLog -ErrPath $ApiErrLog -TargetPath $ApiLog
+ Merge-Log -OutPath $AgentOutLog -ErrPath $AgentErrLog -TargetPath $AgentLog
+ Write-ReportFiles
+}
+
+if ($script:Failure) {
+ Write-Host "ACCEPTANCE: FAIL at $($script:Failure.gate)"
+ if ($null -ne $script:Failure.expected -or $null -ne $script:Failure.actual) {
+ Write-Host "expected $($script:Failure.expected | ConvertTo-Json -Compress -Depth 20) actual=$($script:Failure.actual | ConvertTo-Json -Compress -Depth 20)"
+ }
+ exit 1
+}
+
+Write-Host "ACCEPTANCE: PASS"
+exit 0
diff --git a/tests/integration/test_dashboard_api.py b/tests/integration/test_dashboard_api.py
index 9ba8196..9e3db7b 100644
--- a/tests/integration/test_dashboard_api.py
+++ b/tests/integration/test_dashboard_api.py
@@ -2,6 +2,10 @@
import pytest
+from backend.models.notification import NotificationLog, NotificationRule
+from backend.models.record import CollectedRecord
+from backend.models.task import CollectionTask
+
@pytest.mark.asyncio
async def test_dashboard_stats(client):
@@ -23,3 +27,67 @@ async def test_dashboard_stats_with_source(client, sample_source_data):
data = response.json()["data"]
assert data["sources"]["total"] == 1
assert data["sources"]["enabled"] == 1
+
+
+@pytest.mark.asyncio
+async def test_opinion_monitor_projects_ai_and_feishu_evidence(
+ client, db_session, sample_source_data
+):
+ source_response = await client.post(
+ "/api/v1/sources",
+ json={
+ **sample_source_data,
+ "name": "Aibase 热点",
+ "channel_type": "opencli",
+ "channel_config": {"site": "aibase", "command": "news", "args": {"limit": 1}},
+ "tags": ["opinion"],
+ },
+ )
+ source_id = source_response.json()["data"]["id"]
+
+ task = CollectionTask(source_id=source_id, trigger_type="manual", status="completed")
+ db_session.add(task)
+ await db_session.flush()
+
+ record = CollectedRecord(
+ task_id=task.id,
+ source_id=source_id,
+ raw_data={"title": "AI 新闻", "url": "https://example.com/news"},
+ normalized_data={"title": "AI 新闻", "url": "https://example.com/news"},
+ ai_enrichment={
+ "summary": "国产模型热度上升",
+ "tags": ["AI", "融资"],
+ "sentiment": "positive",
+ },
+ content_hash="opinion-monitor-hash",
+ status="ai_processed",
+ )
+ db_session.add(record)
+ await db_session.flush()
+
+ rule = NotificationRule(
+ name="飞书舆情群",
+ source_id=source_id,
+ trigger_event="on_new_record",
+ notifier_type="feishu",
+ notifier_config={"webhook_url": "https://open.feishu.cn/example"},
+ enabled=True,
+ )
+ db_session.add(rule)
+ await db_session.flush()
+
+ db_session.add(NotificationLog(rule_id=rule.id, record_id=record.id, status="sent"))
+ await db_session.commit()
+
+ response = await client.get("/api/v1/dashboard/opinion-monitor?range=all")
+ assert response.status_code == 200
+ data = response.json()["data"]
+
+ assert data["summary"]["records"] == 1
+ assert data["summary"]["ai_processed"] == 1
+ assert data["summary"]["feishu_sent"] == 1
+ assert data["summary"]["active_feishu_rules"] == 1
+ assert data["tags"] == [{"label": "AI", "count": 1}, {"label": "融资", "count": 1}]
+ assert data["sentiment"] == [{"label": "positive", "count": 1}]
+ assert data["recent"][0]["summary"] == "国产模型热度上升"
+ assert data["recent"][0]["notification_status"] == "sent"
diff --git a/tests/integration/test_presets_api.py b/tests/integration/test_presets_api.py
index b571f10..fb6a63c 100644
--- a/tests/integration/test_presets_api.py
+++ b/tests/integration/test_presets_api.py
@@ -150,6 +150,82 @@ async def test_each_preset_declares_id_label_description_and_params(client):
assert preset["params"].get("channel_type") == preset["channel_type"]
+@pytest.mark.asyncio
+async def test_opinion_monitor_preset_preview_is_read_only(client):
+ response = await client.get("/api/v1/presets/opinion-monitor")
+ assert response.status_code == 200
+ data = response.json()["data"]
+
+ assert data["id"] == "opinion-monitor.visual-feed.v1"
+ assert data["source_count"] == 2
+ assert data["sources"][0]["channel_type"] == "opencli"
+ assert data["sources"][0]["ai_config"]["prompt_template"]
+ assert data["notification"]["notifier_type"] == "feishu"
+ assert data["notification"]["requires_config"] is True
+
+ sources = await client.get("/api/v1/sources")
+ assert sources.json()["data"] == []
+
+
+@pytest.mark.asyncio
+async def test_apply_opinion_monitor_preset_creates_sources_schedules_and_feishu_rule(
+ client,
+):
+ response = await client.post(
+ "/api/v1/presets/opinion-monitor/apply",
+ json={
+ "source_prefix": "实战舆情",
+ "account_slots": [
+ {
+ "label": "account-a",
+ "site": "aibase",
+ "command": "news",
+ "limit": 1,
+ "cron_expression": "*/15 * * * *",
+ "timezone": "Asia/Shanghai",
+ },
+ {
+ "label": "account-b",
+ "site": "aibase",
+ "command": "news",
+ "limit": 2,
+ "cron_expression": "*/30 * * * *",
+ "timezone": "Asia/Shanghai",
+ },
+ ],
+ },
+ )
+
+ assert response.status_code == 201
+ data = response.json()["data"]
+ assert len(data["sources"]) == 2
+ assert len(data["schedules"]) == 2
+ assert data["notification_rule"]["enabled"] is False
+ assert data["notification_rule"]["requires_config"] is True
+ assert data["warnings"][0]["warning"] == "feishu_webhook_url_missing"
+
+ sources = (await client.get("/api/v1/sources?limit=10")).json()["data"]
+ assert {source["name"] for source in sources} == {
+ "实战舆情 · account-a",
+ "实战舆情 · account-b",
+ }
+ assert all(source["channel_config"]["resource_policy"] for source in sources)
+ assert all(source["ai_config"]["processor_type"] == "openai" for source in sources)
+
+ schedules = (await client.get("/api/v1/schedules?limit=10")).json()["data"]
+ assert len(schedules) == 2
+ assert {schedule["cron_expression"] for schedule in schedules} == {
+ "*/15 * * * *",
+ "*/30 * * * *",
+ }
+
+ rules = (await client.get("/api/v1/notifications/rules")).json()["data"]
+ assert len(rules) == 1
+ assert rules[0]["notifier_type"] == "feishu"
+ assert rules[0]["enabled"] is False
+ assert "{{summary}}" in rules[0]["notifier_config"]["content"]
+
+
# ── unit coverage of the presets module directly ────────────────────────────
diff --git a/tests/unit/test_messaging_notifiers.py b/tests/unit/test_messaging_notifiers.py
index 4aac591..fbd71ee 100644
--- a/tests/unit/test_messaging_notifiers.py
+++ b/tests/unit/test_messaging_notifiers.py
@@ -1,8 +1,9 @@
"""Tests for messaging platform notifiers (Feishu, DingTalk, WeCom)."""
-import pytest
from unittest.mock import AsyncMock, MagicMock, patch
+import pytest
+
from backend.notifiers.base import NotificationPayload
from backend.notifiers.registry import list_notifier_types
@@ -110,6 +111,51 @@ async def fake_post(url, json):
assert "Alert: Test Article" in captured["body"]["content"]["post"]["zh_cn"]["title"]
+@pytest.mark.asyncio
+async def test_feishu_template_can_render_ai_enrichment():
+ from backend.notifiers.feishu_notifier import FeishuNotifier
+
+ notifier = FeishuNotifier()
+ payload = _payload(
+ ai_enrichment={
+ "summary": "热度上升",
+ "tags": ["AI", "融资"],
+ "sentiment": "positive",
+ }
+ )
+
+ captured = {}
+
+ async def fake_post(url, json):
+ captured["body"] = json
+ resp = MagicMock()
+ resp.json.return_value = {"code": 0}
+ return resp
+
+ with patch("httpx.AsyncClient") as mock_cls, patch(
+ "socket.getaddrinfo", return_value=[(None, None, None, "", ("93.184.216.34", 0))]
+ ):
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.post = fake_post
+ mock_cls.return_value = mock_client
+
+ result = await notifier.send(
+ {
+ "webhook_url": "https://feishu.ex.com",
+ "content": "摘要={{summary}} 标签={{tags}} 情绪={{sentiment}}",
+ },
+ payload,
+ )
+
+ content = captured["body"]["content"]["post"]["zh_cn"]["content"][0][0]["text"]
+ assert result is True
+ assert "摘要=热度上升" in content
+ assert "标签=AI、融资" in content
+ assert "情绪=positive" in content
+
+
# ── DingTalk ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@@ -165,7 +211,10 @@ async def fake_post(url, json):
mock_cls.return_value = mock_client
await notifier.send(
- {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=x", "secret": "mysecret"},
+ {
+ "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=x",
+ "secret": "mysecret",
+ },
payload,
)
From 384916078234e16b8b2d49ce8efe039a4a81cf03 Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 7 Jul 2026 03:21:04 +0800
Subject: [PATCH 2/2] Add workflow runtime conformance and Docker acceptance
---
.gitignore | 1 +
LIVE_WEBHOOK_ACCEPTANCE.md | 100 ++
README.md | 22 +-
TODOS.md | 53 +
backend/workflow/block_reasons.py | 148 ++
backend/workflow/capability_projection.py | 33 +-
backend/workflow/conformance/__init__.py | 23 +
backend/workflow/conformance/contracts.py | 240 ++++
.../expected_events/happy-path.json | 45 +
.../expected_events/missing-binding.json | 14 +
.../missing-runtime-resource.json | 14 +
.../missing-source-credential.json | 14 +
.../expected_events/missing-webhook-url.json | 19 +
.../expected_events/permission-blocked.json | 20 +
.../webhook-real-delivery.json | 29 +
backend/workflow/event_mirror.py | 262 ++++
backend/workflow/opencli_hda_tracer.py | 513 ++++++-
backend/workflow/runtime_contracts.py | 307 +++++
backend/workflow/runtime_registry.py | 401 ++++--
backend/workflow/webhook_delivery.py | 113 ++
docker-compose.yml | 15 +-
docs/workflow-runtime-conformance.md | 113 ++
.../components/flow/node-context-menu.tsx | 198 +++
.../flow/workflow-agent-proposal.ts | 78 ++
.../flow/workflow-canvas-geometry.ts | 39 +
.../flow/workflow-canvas-interactions.ts | 254 ++++
.../flow/workflow-canvas-surface.tsx | 361 +++++
.../flow/workflow-editor-effects.ts | 106 ++
.../flow/workflow-editor-overlays.tsx | 128 ++
.../flow/workflow-editor-selectors.ts | 49 +
frontend/components/flow/workflow-editor.tsx | 1210 ++++-------------
.../flow/workflow-keyboard-shortcuts.ts | 212 +++
.../flow/workflow-node-menu-actions.ts | 143 ++
frontend/lib/flow/store-layout-actions.ts | 308 +++++
frontend/lib/flow/store-slices.ts | 306 +++++
frontend/lib/flow/store-utils.ts | 15 +
frontend/lib/flow/store.ts | 566 +-------
.../proposal.md | 48 +
.../spec.md | 80 ++
.../tasks.md | 49 +
.../workflow-runtime-conformance/proposal.md | 34 +
.../workflow-runtime-conformance/spec.md | 43 +
.../workflow-runtime-conformance/tasks.md | 19 +
scripts/install-agent.sh | 2 +-
tests/fixtures/__init__.py | 1 +
tests/fixtures/workflow_conformance.py | 275 ++++
.../integration/test_generic_webhook_live.py | 97 ++
.../test_workflow_capabilities_api.py | 26 +-
.../integration/test_workflow_compile_api.py | 19 +-
.../integration/test_workflow_conformance.py | 586 ++++++++
.../test_workflow_opencli_hda_trace_api.py | 96 ++
51 files changed, 6148 insertions(+), 1699 deletions(-)
create mode 100644 LIVE_WEBHOOK_ACCEPTANCE.md
create mode 100644 TODOS.md
create mode 100644 backend/workflow/block_reasons.py
create mode 100644 backend/workflow/conformance/__init__.py
create mode 100644 backend/workflow/conformance/contracts.py
create mode 100644 backend/workflow/conformance/expected_events/happy-path.json
create mode 100644 backend/workflow/conformance/expected_events/missing-binding.json
create mode 100644 backend/workflow/conformance/expected_events/missing-runtime-resource.json
create mode 100644 backend/workflow/conformance/expected_events/missing-source-credential.json
create mode 100644 backend/workflow/conformance/expected_events/missing-webhook-url.json
create mode 100644 backend/workflow/conformance/expected_events/permission-blocked.json
create mode 100644 backend/workflow/conformance/expected_events/webhook-real-delivery.json
create mode 100644 backend/workflow/event_mirror.py
create mode 100644 backend/workflow/runtime_contracts.py
create mode 100644 backend/workflow/webhook_delivery.py
create mode 100644 docs/workflow-runtime-conformance.md
create mode 100644 frontend/components/flow/node-context-menu.tsx
create mode 100644 frontend/components/flow/workflow-agent-proposal.ts
create mode 100644 frontend/components/flow/workflow-canvas-geometry.ts
create mode 100644 frontend/components/flow/workflow-canvas-interactions.ts
create mode 100644 frontend/components/flow/workflow-canvas-surface.tsx
create mode 100644 frontend/components/flow/workflow-editor-effects.ts
create mode 100644 frontend/components/flow/workflow-editor-overlays.tsx
create mode 100644 frontend/components/flow/workflow-editor-selectors.ts
create mode 100644 frontend/components/flow/workflow-keyboard-shortcuts.ts
create mode 100644 frontend/components/flow/workflow-node-menu-actions.ts
create mode 100644 frontend/lib/flow/store-layout-actions.ts
create mode 100644 frontend/lib/flow/store-slices.ts
create mode 100644 frontend/lib/flow/store-utils.ts
create mode 100644 openspec/changes/runtime-conformance-next-granularity/proposal.md
create mode 100644 openspec/changes/runtime-conformance-next-granularity/specs/runtime-conformance-next-granularity/spec.md
create mode 100644 openspec/changes/runtime-conformance-next-granularity/tasks.md
create mode 100644 openspec/changes/workflow-runtime-conformance/proposal.md
create mode 100644 openspec/changes/workflow-runtime-conformance/specs/workflow-runtime-conformance/spec.md
create mode 100644 openspec/changes/workflow-runtime-conformance/tasks.md
create mode 100644 tests/fixtures/__init__.py
create mode 100644 tests/fixtures/workflow_conformance.py
create mode 100644 tests/integration/test_generic_webhook_live.py
create mode 100644 tests/integration/test_workflow_conformance.py
diff --git a/.gitignore b/.gitignore
index e192368..cc8bad8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@ htmlcov/
*.coveragerc
coverage.xml
.tmp-smoke/
+.tmp/opencli-conformance/
# Database files
*.db
diff --git a/LIVE_WEBHOOK_ACCEPTANCE.md b/LIVE_WEBHOOK_ACCEPTANCE.md
new file mode 100644
index 0000000..71ce2ca
--- /dev/null
+++ b/LIVE_WEBHOOK_ACCEPTANCE.md
@@ -0,0 +1,100 @@
+# Live Webhook Acceptance
+
+Date: 2026-07-07
+
+## WSL Test Environment
+
+- Repo: `/mnt/c/c/Users/Administrator/projects/opencli-admin-backend`
+- WSL distro: Ubuntu
+- Working Python: `3.12.13`
+- WSL venv: `/root/.cache/codex/venvs/opencli-admin-backend-py312`
+- Install command:
+
+```bash
+uv venv /root/.cache/codex/venvs/opencli-admin-backend-py312 \
+ --python /root/.local/share/uv/python/cpython-3.12-linux-x86_64-gnu/bin/python3.12
+
+uv pip install \
+ --python /root/.cache/codex/venvs/opencli-admin-backend-py312/bin/python \
+ -e '.[dev]'
+```
+
+Notes:
+
+- The repo-local `.venv` is a Windows venv (`Scripts/python.exe`), not usable from WSL.
+- Ubuntu's system Python is `3.14.4`; it was not used for acceptance because `lxml==5.4.0` does not build cleanly on this image.
+- `uv python install 3.11` did not complete in this WSL session, but uv already had Python `3.12.13`, which satisfies the project `>=3.11` requirement.
+
+## Baseline Pytest Acceptance
+
+Command:
+
+```bash
+cd /mnt/c/c/Users/Administrator/projects/opencli-admin-backend
+/root/.cache/codex/venvs/opencli-admin-backend-py312/bin/python \
+ -m pytest -q -m 'not live' --maxfail=20
+```
+
+Result:
+
+```text
+1430 passed, 1 skipped, 9 deselected, 92 warnings in 235.18s
+Required test coverage of 80% reached. Total coverage: 89.72%
+```
+
+One test adjustment was needed: compile API binding assertions now allow the runtime binding to include the new `contract` manifest while still asserting the original stable binding fields and matching `contract.bindingId`.
+
+## Generic Webhook Live Acceptance
+
+Added test:
+
+```text
+tests/integration/test_generic_webhook_live.py
+```
+
+Behavior:
+
+- If `OPENCLI_GENERIC_WEBHOOK_LIVE_URL` is set, the test posts to that URL.
+- If unset, the test creates a temporary Webhook.site token with `POST https://webhook.site/token`.
+- For Webhook.site URLs, the test reads `request/latest/raw` and verifies the captured payload.
+- The test exercises the real project path: `execute_workflow_webhook_delivery()` -> `WebhookNotifier` -> public HTTPS POST.
+
+Command:
+
+```bash
+cd /mnt/c/c/Users/Administrator/projects/opencli-admin-backend
+/root/.cache/codex/venvs/opencli-admin-backend-py312/bin/python \
+ -m pytest -q -m live tests/integration/test_generic_webhook_live.py --no-cov
+```
+
+Result:
+
+```text
+1 passed, 27 warnings in 2.89s
+```
+
+Manual smoke also passed before the pytest was added:
+
+```text
+delivery_result.delivered=true
+captured_event=workflow.evidence_batch.ready
+captured_title=WSL live webhook acceptance
+```
+
+## Secret Handling
+
+No Feishu, DingTalk, WeCom, Hookdeck, or other private keys were added to the repo or written into this document.
+
+For provider-specific live checks, inject secrets through WSL environment variables or an external secret manager:
+
+```bash
+export OPENCLI_FEISHU_WEBHOOK_URL='...'
+export OPENCLI_DINGTALK_WEBHOOK_URL='...'
+export OPENCLI_WECOM_WEBHOOK_URL='...'
+```
+
+Then add or run provider-specific `pytest -m live` tests that skip unless the matching env var is present.
+
+## Next Step
+
+After generic webhook live is green, wire Feishu/DingTalk/WeCom live smoke tests behind env-var skips. Keep their keys out of git, shell history, docs, and chat.
diff --git a/README.md b/README.md
index 52fa5ba..9d5a2e3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# OpenCLI Admin
-[](https://hub.docker.com/u/xjh1994)
+[](https://hub.docker.com/u/2233admin)
**现代化的数据采集系统** — 可视化管理多渠道数据采集,接入 [opencli](https://github.com/jackwener/opencli) 驱动国内外主流平台,支持 AI 处理、多节点分布式调度与实时通知推送。
@@ -11,10 +11,10 @@
- 后端位于 `backend/`,前端通过 `/api/v1/*` 与 `/health` 代理对接后端。
- 扩展仍在 `chrome/extension-src/` 下独立构建。
-**OpenCLI WebUI** OpenCLI 可视化界面 [opencli-webui](https://github.com/xjh1994/opencli-webui)
+**OpenCLI WebUI** OpenCLI 可视化界面 [opencli-webui](https://github.com/2233admin/opencli-webui)
**仪表盘**
-
+
**Agent 节点自动路由**
@@ -271,7 +271,7 @@ docker run -d --name opencli-agent --restart unless-stopped \
-e CENTRAL_API_URL=http://:8030 \
-e AGENT_REGISTER=ws -e AGENT_MODE=bridge \
-p 19823:19823 \
- xjh1994/opencli-admin-agent:0.3.6
+ 2233admin/opencli-admin-agent:0.3.6
# HTTP 模式(局域网)
docker run -d --name opencli-agent --restart unless-stopped \
@@ -279,7 +279,7 @@ docker run -d --name opencli-agent --restart unless-stopped \
-e CENTRAL_API_URL=http://:8030 \
-e AGENT_REGISTER=http -e AGENT_MODE=bridge \
-p 19823:19823 \
- xjh1994/opencli-admin-agent:0.3.6
+ 2233admin/opencli-admin-agent:0.3.6
```
**一键脚本安装**
@@ -472,20 +472,20 @@ TAG=0.3.6
docker buildx build --builder multiarch \
--platform linux/amd64,linux/arm64 \
--build-arg IMAGE_TAG=${TAG} \
- -t xjh1994/opencli-admin-api:${TAG} --push .
+ -t 2233admin/opencli-admin-api:${TAG} --push .
# Agent 基础版(~100 MB,通过宿主机 Chrome 连接)
docker buildx build --builder multiarch \
--platform linux/amd64,linux/arm64 \
-f agent/Dockerfile \
- -t xjh1994/opencli-admin-agent:${TAG} --push .
+ -t 2233admin/opencli-admin-agent:${TAG} --push .
# Agent 内置 Chrome 版(~450 MB,完全自包含)
docker buildx build --builder multiarch \
--platform linux/amd64,linux/arm64 \
-f agent/Dockerfile \
--build-arg INSTALL_CHROME=true \
- -t xjh1994/opencli-admin-agent:${TAG}-chrome --push .
+ -t 2233admin/opencli-admin-agent:${TAG}-chrome --push .
```
如需并行构建所有镜像:
@@ -494,13 +494,13 @@ docker buildx build --builder multiarch \
TAG=0.3.6
docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 \
--build-arg IMAGE_TAG=${TAG} \
- -t xjh1994/opencli-admin-api:${TAG} --push . > /tmp/build-api.log 2>&1 &
+ -t 2233admin/opencli-admin-api:${TAG} --push . > /tmp/build-api.log 2>&1 &
docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 \
-f agent/Dockerfile \
- -t xjh1994/opencli-admin-agent:${TAG} --push . > /tmp/build-agent.log 2>&1 &
+ -t 2233admin/opencli-admin-agent:${TAG} --push . > /tmp/build-agent.log 2>&1 &
docker buildx build --builder multiarch --platform linux/amd64,linux/arm64 \
-f agent/Dockerfile --build-arg INSTALL_CHROME=true \
- -t xjh1994/opencli-admin-agent:${TAG}-chrome --push . > /tmp/build-agent-chrome.log 2>&1 &
+ -t 2233admin/opencli-admin-agent:${TAG}-chrome --push . > /tmp/build-agent-chrome.log 2>&1 &
wait && echo "done"
```
diff --git a/TODOS.md b/TODOS.md
new file mode 100644
index 0000000..b086df4
--- /dev/null
+++ b/TODOS.md
@@ -0,0 +1,53 @@
+# TODOs
+
+## Workflow Runtime Conformance
+
+### Maintain config-blocked conformance cases
+
+- **Status:** First taxonomy-backed fixtures are implemented for missing webhook URL, missing source credential, and missing runtime resource.
+- **What:** Keep `config-blocked` conformance cases current as new runtime bindings add config, credential, or resource gates.
+- **Why:** The first conformance slice covers happy path, permission-blocked, and missing-binding evidence; config absence is a separate failure class that open-source users must be able to diagnose without guessing.
+- **Pros:** Extends the drift gate beyond permissions and missing bindings, and makes live/preview/simulated claims harder to overstate.
+- **Cons:** New bindings must register stable reason taxonomy entries before tests can assert exact failures without churn.
+- **Context:** `Administrator-codex-opinion-monitor-quickstart-design-20260706-182303.md` defines runtime truth as registry declaration plus executable fixture plus observed event transcript. Config-blocked cases were intentionally deferred from the first PR-sized slice to keep the initial harness small.
+- **Depends on / blocked by:** Future binding-specific config gates and their stable block reason definitions.
+
+### Maintain SSE event stream smoke coverage
+
+- **Status:** Canonical happy-path `/events/stream` smoke coverage now reuses the snapshot expected transcript matcher.
+- **What:** Keep `/api/v1/workflows/runs/{runId}/events/stream` smoke coverage aligned with the snapshot-based conformance matcher as event shapes evolve.
+- **Why:** The conformance matcher should use the deterministic `/events` snapshot as its primary evidence source, but the live UI still depends on the stream endpoint.
+- **Pros:** Protects the live event-stream user path without making the main conformance gate depend on polling windows or SSE timing.
+- **Cons:** SSE tests are more timing-sensitive than snapshot tests and should stay out of the primary conformance matcher.
+- **Context:** The `/plan-eng-review` decision for performance was to use `/events` snapshots for conformance evidence and reserve `/events/stream` for a later smoke test only.
+- **Depends on / blocked by:** Future event shape changes must update both snapshot expected transcripts and SSE parser expectations.
+
+### Maintain ODP/Redis-stream conformance after the interim harness
+
+- **Status:** Workflow-run event mirror conformance now publishes stable event facts through the Redis stream interface and reads them back with the shared transcript matcher.
+- **What:** Keep ODP/Redis-stream conformance cases current as the event mirror moves from fixture Redis clients to deployment Redis and later ODP consumers.
+- **Why:** The first conformance slice certifies the current `/events` API, but the longer-term architecture makes ODP/event streams the runtime source of truth.
+- **Pros:** Prevents the interim snapshot harness from becoming the permanent definition of runtime truth, and keeps the open-source conformance story aligned with the event-stream-first architecture.
+- **Cons:** Expands scope into executor/source-of-truth migration and should wait until the transcript schema is stable.
+- **Context:** The current design deliberately separates interim workflow-run event evidence from long-term ODP/Redis event-stream evidence.
+- **Depends on / blocked by:** Deployment Redis/ODP consumer coverage.
+
+### Maintain real node I/O contract coverage
+
+- **Status:** Runtime bindings now declare stable input shape, output shape, permission gate, config gate, event shape, and fixture coverage through `backend/workflow/runtime_contracts.py`.
+- **What:** Keep each new runtime binding in the contract table before exposing it as runnable in compile output or Canvas capability/status surfaces.
+- **Why:** A binding is not runtime-certified just because compile can produce a node or the UI can place it on the Canvas; it needs a stable I/O contract first.
+- **Pros:** Prevents runnable/status drift and keeps resource internals out of user-entered fields.
+- **Cons:** New runtime bindings must update both the contract table and focused fixture coverage before they can honestly appear runnable.
+- **Context:** The contract is projected into both registry metadata and capability manifests; webhook delivery now has its own deterministic request-capture fixture.
+- **Depends on / blocked by:** Future runtime bindings must add contract declarations and fixture evidence before being exposed as runnable.
+
+### Maintain webhook real delivery fixtures
+
+- **Status:** `workflow.notifier.webhook.send` now sends through the registered webhook notifier when send permission, configured URL, and upstream EvidenceBatch projection are present.
+- **What:** Keep the success request-capture fixture and negative missing-permission, missing-URL, and missing-projection fixtures aligned with the delivery payload.
+- **Why:** Webhook delivery is the final runtime layer; regressions here would silently turn real delivery back into a projection-only claim.
+- **Pros:** Confirms actual POST construction while keeping SSRF-safe notifier plumbing and blocked preconditions visible.
+- **Cons:** Payload schema changes must update both request-capture assertions and expected transcript evidence.
+- **Context:** Capability/status surfaces remain blocked by default because each run still needs user configuration and upstream projection input, but the backend delivery path is now executable.
+- **Depends on / blocked by:** Future webhook payload schema or notifier security changes.
diff --git a/backend/workflow/block_reasons.py b/backend/workflow/block_reasons.py
new file mode 100644
index 0000000..677ccd8
--- /dev/null
+++ b/backend/workflow/block_reasons.py
@@ -0,0 +1,148 @@
+"""Stable workflow-run block reason taxonomy."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+BlockReasonCategory = Literal[
+ "missing_config",
+ "missing_source_credential",
+ "missing_runtime_resource",
+ "missing_permission",
+ "missing_runtime_binding",
+]
+
+FETCH_PERMISSION_REQUIRED = "fetch_permission_required"
+SEND_PERMISSION_REQUIRED = "send_permission_required"
+MISSING_DELIVERY_PROJECTION = "missing_delivery_projection"
+MISSING_RUNTIME_BINDING = "missing_runtime_binding"
+MISSING_RUNTIME_IO_CONTRACT = "missing_runtime_io_contract"
+MISSING_RUNTIME_PARAMETER = "missing_runtime_parameter"
+MISSING_SOURCE_CREDENTIAL = "missing_source_credential"
+MISSING_TOOL_CAPABILITY_BINDING = "missing_tool_capability_binding"
+MISSING_TURBOPUSH_CONTENT_TYPE = "missing_turbopush_content_type"
+MISSING_TURBOPUSH_SERVICE = "missing_turbopush_service"
+SOURCE_OUTPUT_REQUIRED = "source_output_required"
+
+
+@dataclass(frozen=True)
+class WorkflowBlockReasonDefinition:
+ code: str
+ category: BlockReasonCategory
+ stable_fields: tuple[str, ...]
+ volatile_fields: tuple[str, ...] = ()
+ description: str = ""
+
+
+WORKFLOW_BLOCK_REASON_TAXONOMY: dict[str, WorkflowBlockReasonDefinition] = {
+ FETCH_PERMISSION_REQUIRED: WorkflowBlockReasonDefinition(
+ code=FETCH_PERMISSION_REQUIRED,
+ category="missing_permission",
+ stable_fields=("code", "source", "details.bindingId", "details.requiredPermission"),
+ description="Source fetch is blocked because canFetchNetwork is false.",
+ ),
+ SEND_PERMISSION_REQUIRED: WorkflowBlockReasonDefinition(
+ code=SEND_PERMISSION_REQUIRED,
+ category="missing_permission",
+ stable_fields=("code", "source", "details.bindingId", "details.requiredPermission"),
+ description="Notification delivery is blocked because canSendNotifications is false.",
+ ),
+ MISSING_DELIVERY_PROJECTION: WorkflowBlockReasonDefinition(
+ code=MISSING_DELIVERY_PROJECTION,
+ category="missing_config",
+ stable_fields=("code", "source", "details.bindingId", "details.required_params"),
+ volatile_fields=("message",),
+ description="Delivery is blocked until webhook URL and projection inputs are configured.",
+ ),
+ MISSING_RUNTIME_BINDING: WorkflowBlockReasonDefinition(
+ code=MISSING_RUNTIME_BINDING,
+ category="missing_runtime_binding",
+ stable_fields=("code", "source", "details.kind", "details.capability"),
+ volatile_fields=("message",),
+ description="Compiled node has no registered runtime binding.",
+ ),
+ MISSING_RUNTIME_IO_CONTRACT: WorkflowBlockReasonDefinition(
+ code=MISSING_RUNTIME_IO_CONTRACT,
+ category="missing_runtime_binding",
+ stable_fields=("code", "source", "details.bindingId"),
+ volatile_fields=("message",),
+ description="Runtime binding exists but has no declared node I/O contract.",
+ ),
+ MISSING_RUNTIME_PARAMETER: WorkflowBlockReasonDefinition(
+ code=MISSING_RUNTIME_PARAMETER,
+ category="missing_config",
+ stable_fields=("code", "source", "details.required_params"),
+ volatile_fields=("message",),
+ description="Runtime binding cannot be built because required node params are absent.",
+ ),
+ MISSING_SOURCE_CREDENTIAL: WorkflowBlockReasonDefinition(
+ code=MISSING_SOURCE_CREDENTIAL,
+ category="missing_source_credential",
+ stable_fields=(
+ "code",
+ "source",
+ "details.bindingId",
+ "details.requiredCredentialKey",
+ ),
+ volatile_fields=("message",),
+ description="Source fetch requires a saved credential reference that is absent.",
+ ),
+ MISSING_TOOL_CAPABILITY_BINDING: WorkflowBlockReasonDefinition(
+ code=MISSING_TOOL_CAPABILITY_BINDING,
+ category="missing_runtime_binding",
+ stable_fields=("code", "source", "details.toolCapabilityId"),
+ volatile_fields=("message",),
+ description="Tool-capability node has no registered backend tool binding.",
+ ),
+ MISSING_TURBOPUSH_CONTENT_TYPE: WorkflowBlockReasonDefinition(
+ code=MISSING_TURBOPUSH_CONTENT_TYPE,
+ category="missing_config",
+ stable_fields=("code", "source", "details.required_params"),
+ volatile_fields=("message",),
+ description="TurboPush publish cannot bind without a supported content type.",
+ ),
+ MISSING_TURBOPUSH_SERVICE: WorkflowBlockReasonDefinition(
+ code=MISSING_TURBOPUSH_SERVICE,
+ category="missing_runtime_resource",
+ stable_fields=("code", "source", "details.provider"),
+ volatile_fields=("message", "details.required_params"),
+ description="TurboPush local runtime service resource is not configured.",
+ ),
+ SOURCE_OUTPUT_REQUIRED: WorkflowBlockReasonDefinition(
+ code=SOURCE_OUTPUT_REQUIRED,
+ category="missing_config",
+ stable_fields=("code", "source", "details.bindingId", "details.liveMode"),
+ volatile_fields=("message",),
+ description="Fixture/mock source fetch needs source outputs before downstream execution.",
+ ),
+}
+
+
+def block_reason_definition(code: str) -> WorkflowBlockReasonDefinition | None:
+ return WORKFLOW_BLOCK_REASON_TAXONOMY.get(code)
+
+
+def block_reason_category(code: str) -> BlockReasonCategory | None:
+ definition = block_reason_definition(code)
+ return definition.category if definition else None
+
+
+__all__ = [
+ "BlockReasonCategory",
+ "FETCH_PERMISSION_REQUIRED",
+ "MISSING_DELIVERY_PROJECTION",
+ "MISSING_RUNTIME_BINDING",
+ "MISSING_RUNTIME_IO_CONTRACT",
+ "MISSING_RUNTIME_PARAMETER",
+ "MISSING_SOURCE_CREDENTIAL",
+ "MISSING_TOOL_CAPABILITY_BINDING",
+ "MISSING_TURBOPUSH_CONTENT_TYPE",
+ "MISSING_TURBOPUSH_SERVICE",
+ "SEND_PERMISSION_REQUIRED",
+ "SOURCE_OUTPUT_REQUIRED",
+ "WORKFLOW_BLOCK_REASON_TAXONOMY",
+ "WorkflowBlockReasonDefinition",
+ "block_reason_category",
+ "block_reason_definition",
+]
diff --git a/backend/workflow/capability_projection.py b/backend/workflow/capability_projection.py
index d98049b..b2dacb4 100644
--- a/backend/workflow/capability_projection.py
+++ b/backend/workflow/capability_projection.py
@@ -19,6 +19,7 @@
)
from backend.workflow.node_registry import WORKFLOW_PRIMITIVE_IDS
from backend.workflow.opencli_adapter_nodes import get_opencli_adapter_node_summary
+from backend.workflow.runtime_contracts import runtime_io_contract_manifest
from backend.workflow.runtime_registry import (
COLLECTION_OUTPUT_BINDING_ID,
DEMAND_DRAFT_BINDING_ID,
@@ -69,6 +70,7 @@ def _capability(
source: str | None = None,
manifest: dict[str, object] | None = None,
) -> WorkflowRuntimeCapability:
+ resolved_manifest = _manifest_with_runtime_contract(manifest or {}, runtime_binding)
return WorkflowRuntimeCapability(
id=id,
label=label,
@@ -85,7 +87,7 @@ def _capability(
missing=missing or [],
tags=tags or [],
source=source,
- manifest=manifest or {},
+ manifest=resolved_manifest,
)
@@ -385,13 +387,12 @@ def _catalog_capabilities() -> list[WorkflowRuntimeCapability]:
provider="webhook",
notifier_type="webhook",
runtime_binding=WEBHOOK_NOTIFY_BINDING_ID,
- reason="Backend notifier and workflow sink contract exist, but live "
- "Canvas delivery waits for EvidenceBatch projection, permission, "
- "and configured webhook URL.",
+ reason="Backend notifier and real workflow delivery path exist; "
+ "each run still requires send permission, an upstream EvidenceBatch "
+ "projection, and a configured webhook URL.",
missing=[
- "evidencebatch_projection_api",
- "delivery_projection",
- "notification_permission",
+ "evidencebatch_projection_input",
+ "send_permission",
"webhook_url_configuration",
],
tags=["catalog", "notify", "webhook"],
@@ -732,11 +733,11 @@ def _notifier_capabilities() -> list[WorkflowRuntimeCapability]:
provider="webhook",
notifier_type="webhook",
runtime_binding=WEBHOOK_NOTIFY_BINDING_ID,
- reason="The guarded webhook notifier exists, but workflow "
- "delivery still requires projection and URL resources.",
+ reason="The guarded webhook notifier is wired into workflow "
+ "delivery; each run still requires projection input and URL "
+ "configuration.",
missing=[
- "evidencebatch_projection_api",
- "delivery_projection",
+ "evidencebatch_projection_input",
"webhook_url_configuration",
],
tags=["notifier", "output", "webhook"],
@@ -924,5 +925,15 @@ def _read_manifest_runtime_binding(manifest: dict[str, object]) -> str | None:
return binding if isinstance(binding, str) else None
+def _manifest_with_runtime_contract(
+ manifest: dict[str, object],
+ runtime_binding: str | None,
+) -> dict[str, object]:
+ contract = runtime_io_contract_manifest(runtime_binding)
+ if contract is None:
+ return manifest
+ return {**manifest, "contract": contract}
+
+
def _label_from_id(value: str) -> str:
return value.rsplit(".", 1)[-1].replace("-", " ").title()
diff --git a/backend/workflow/conformance/__init__.py b/backend/workflow/conformance/__init__.py
new file mode 100644
index 0000000..442db02
--- /dev/null
+++ b/backend/workflow/conformance/__init__.py
@@ -0,0 +1,23 @@
+"""Workflow runtime conformance helpers."""
+
+from backend.workflow.conformance.contracts import (
+ ConformanceCaseResult,
+ ExpectedWorkflowRunEvent,
+ RuntimePassport,
+ TranscriptMatchResult,
+ load_expected_events,
+ match_expected_events,
+ parse_sse_node_events,
+ write_runtime_passport,
+)
+
+__all__ = [
+ "ConformanceCaseResult",
+ "ExpectedWorkflowRunEvent",
+ "RuntimePassport",
+ "TranscriptMatchResult",
+ "load_expected_events",
+ "match_expected_events",
+ "parse_sse_node_events",
+ "write_runtime_passport",
+]
diff --git a/backend/workflow/conformance/contracts.py b/backend/workflow/conformance/contracts.py
new file mode 100644
index 0000000..2f7ad6b
--- /dev/null
+++ b/backend/workflow/conformance/contracts.py
@@ -0,0 +1,240 @@
+"""Contracts for workflow runtime conformance transcripts and passports."""
+
+# ruff: noqa: N815
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+from backend.schemas.workflow import WorkflowNodeRunEventType, WorkflowRunStatus
+from backend.workflow.block_reasons import BlockReasonCategory, block_reason_category
+
+
+class ExpectedWorkflowRunEvent(BaseModel):
+ nodeId: str = Field(..., min_length=1)
+ eventType: WorkflowNodeRunEventType
+ bindingId: str | None = None
+ expectedNodeStatus: WorkflowRunStatus | None = None
+ blockReasonCode: str | None = None
+ blockReasonCategory: BlockReasonCategory | None = None
+ messageContains: str | None = None
+ detailsSubset: dict[str, Any] = Field(default_factory=dict)
+ blockReasonDetailsSubset: dict[str, Any] = Field(default_factory=dict)
+
+
+class TranscriptMatchResult(BaseModel):
+ passed: bool
+ failures: list[str] = Field(default_factory=list)
+ matchedEvents: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class ConformanceCaseResult(BaseModel):
+ id: str = Field(..., min_length=1)
+ status: Literal["passed", "failed"]
+ bindings: list[str] = Field(default_factory=list)
+ blockedReasons: list[str] = Field(default_factory=list)
+ failures: list[str] = Field(default_factory=list)
+
+
+class RuntimePassportBinding(BaseModel):
+ status: Literal["conformance-known", "failed", "preview-only"]
+ evidenceCases: list[str] = Field(default_factory=list)
+ blockedReasons: list[str] = Field(default_factory=list)
+
+
+class RuntimePassport(BaseModel):
+ schemaVersion: Literal[1] = 1
+ generatedAt: str
+ eventSource: Literal["workflow-run-events"] = "workflow-run-events"
+ status: Literal["conformant", "partial", "preview-only", "failed"]
+ cases: list[ConformanceCaseResult]
+ bindings: dict[str, RuntimePassportBinding] = Field(default_factory=dict)
+
+
+def load_expected_events(path: Path) -> list[ExpectedWorkflowRunEvent]:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(payload, list):
+ raise ValueError(f"Expected event transcript must be a list: {path}")
+ return [ExpectedWorkflowRunEvent.model_validate(item) for item in payload]
+
+
+def parse_sse_node_events(body: str) -> list[dict[str, Any]]:
+ events: list[dict[str, Any]] = []
+ for block in body.replace("\r\n", "\n").strip().split("\n\n"):
+ event_name = ""
+ data_lines: list[str] = []
+ for line in block.split("\n"):
+ if line.startswith("event:"):
+ event_name = line.removeprefix("event:").strip()
+ elif line.startswith("data:"):
+ data_lines.append(line.removeprefix("data:").strip())
+
+ if event_name != "node_event" or not data_lines:
+ continue
+ payload = json.loads("\n".join(data_lines))
+ if not isinstance(payload, dict):
+ raise ValueError("SSE node_event payload must be a JSON object")
+ events.append(payload)
+ return events
+
+
+def match_expected_events(
+ actual_events: list[dict[str, Any]],
+ expected_events: list[ExpectedWorkflowRunEvent],
+) -> TranscriptMatchResult:
+ failures: list[str] = []
+ matched: list[dict[str, Any]] = []
+ cursor = 0
+
+ for expected in expected_events:
+ match_index = None
+ mismatch_notes: list[str] = []
+ for index in range(cursor, len(actual_events)):
+ actual = actual_events[index]
+ event_failures = _event_failures(actual, expected)
+ if not event_failures:
+ match_index = index
+ break
+ mismatch_notes = event_failures
+
+ if match_index is None:
+ failures.append(
+ "No matching event for "
+ f"{expected.nodeId}/{expected.eventType}: "
+ + "; ".join(mismatch_notes or ["event not present"])
+ )
+ continue
+
+ matched.append(actual_events[match_index])
+ cursor = match_index + 1
+
+ return TranscriptMatchResult(
+ passed=not failures,
+ failures=failures,
+ matchedEvents=matched,
+ )
+
+
+def write_runtime_passport(
+ artifact_dir: Path,
+ case_results: list[ConformanceCaseResult],
+ *,
+ status: Literal["conformant", "partial", "preview-only", "failed"] | None = None,
+) -> Path:
+ artifact_dir.mkdir(parents=True, exist_ok=True)
+ passport = RuntimePassport(
+ generatedAt=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
+ status=status
+ or ("failed" if any(case.status == "failed" for case in case_results) else "partial"),
+ cases=case_results,
+ bindings=_binding_evidence(case_results),
+ )
+ path = artifact_dir / "opencli-runtime-passport.json"
+ path.write_text(
+ json.dumps(passport.model_dump(mode="json"), indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ return path
+
+
+def _event_failures(
+ actual: dict[str, Any],
+ expected: ExpectedWorkflowRunEvent,
+) -> list[str]:
+ failures: list[str] = []
+ if actual.get("nodeId") != expected.nodeId:
+ failures.append(f"nodeId {actual.get('nodeId')!r} != {expected.nodeId!r}")
+ if actual.get("eventType") != expected.eventType:
+ failures.append(f"eventType {actual.get('eventType')!r} != {expected.eventType!r}")
+
+ if expected.bindingId is not None and _event_binding_id(actual) != expected.bindingId:
+ failures.append(f"bindingId {_event_binding_id(actual)!r} != {expected.bindingId!r}")
+
+ if "blockReasonCode" in expected.model_fields_set:
+ actual_block = actual.get("blockReason")
+ actual_code = actual_block.get("code") if isinstance(actual_block, dict) else None
+ if actual_code != expected.blockReasonCode:
+ failures.append(f"blockReasonCode {actual_code!r} != {expected.blockReasonCode!r}")
+
+ if "blockReasonCategory" in expected.model_fields_set:
+ actual_block = actual.get("blockReason")
+ actual_code = actual_block.get("code") if isinstance(actual_block, dict) else None
+ actual_category = (
+ block_reason_category(actual_code) if isinstance(actual_code, str) else None
+ )
+ if actual_category != expected.blockReasonCategory:
+ failures.append(
+ f"blockReasonCategory {actual_category!r} != {expected.blockReasonCategory!r}"
+ )
+
+ if expected.messageContains:
+ message = actual.get("message")
+ if not isinstance(message, str) or expected.messageContains not in message:
+ failures.append(f"message does not contain {expected.messageContains!r}")
+
+ if expected.detailsSubset and not _is_subset(
+ expected.detailsSubset,
+ _read_dict(actual.get("details")),
+ ):
+ failures.append("detailsSubset did not match")
+
+ if expected.blockReasonDetailsSubset and not _is_subset(
+ expected.blockReasonDetailsSubset,
+ _read_dict(_read_dict(actual.get("blockReason")).get("details")),
+ ):
+ failures.append("blockReasonDetailsSubset did not match")
+
+ return failures
+
+
+def _binding_evidence(
+ case_results: list[ConformanceCaseResult],
+) -> dict[str, RuntimePassportBinding]:
+ by_binding: dict[str, RuntimePassportBinding] = {}
+ for case in case_results:
+ for binding in case.bindings:
+ evidence = by_binding.setdefault(
+ binding,
+ RuntimePassportBinding(
+ status="conformance-known" if case.status == "passed" else "failed"
+ ),
+ )
+ if case.id not in evidence.evidenceCases:
+ evidence.evidenceCases.append(case.id)
+ for reason in case.blockedReasons:
+ if reason not in evidence.blockedReasons:
+ evidence.blockedReasons.append(reason)
+ if case.status == "failed":
+ evidence.status = "failed"
+ return by_binding
+
+
+def _event_binding_id(event: dict[str, Any]) -> str | None:
+ details = _read_dict(event.get("details"))
+ if isinstance(details.get("bindingId"), str):
+ return details["bindingId"]
+ block_details = _read_dict(_read_dict(event.get("blockReason")).get("details"))
+ binding_id = block_details.get("bindingId") or block_details.get("binding_id")
+ return binding_id if isinstance(binding_id, str) else None
+
+
+def _is_subset(expected: dict[str, Any], actual: dict[str, Any]) -> bool:
+ for key, expected_value in expected.items():
+ if key not in actual:
+ return False
+ actual_value = actual[key]
+ if isinstance(expected_value, dict):
+ if not isinstance(actual_value, dict) or not _is_subset(expected_value, actual_value):
+ return False
+ elif actual_value != expected_value:
+ return False
+ return True
+
+
+def _read_dict(value: Any) -> dict[str, Any]:
+ return value if isinstance(value, dict) else {}
diff --git a/backend/workflow/conformance/expected_events/happy-path.json b/backend/workflow/conformance/expected_events/happy-path.json
new file mode 100644
index 0000000..664101b
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/happy-path.json
@@ -0,0 +1,45 @@
+[
+ {
+ "nodeId": "source-jin10",
+ "eventType": "partial",
+ "messageContains": "Runtime source output",
+ "detailsSubset": {
+ "itemCount": 2,
+ "outputPort": "items[]"
+ }
+ },
+ {
+ "nodeId": "router-importance",
+ "eventType": "partial",
+ "bindingId": "workflow.router.route",
+ "detailsSubset": {
+ "routedCandidateCount": 1
+ }
+ },
+ {
+ "nodeId": "inbox-review",
+ "eventType": "partial",
+ "bindingId": "workflow.inbox.store",
+ "detailsSubset": {
+ "target": "macro-watch",
+ "storedRecordCount": 1
+ }
+ },
+ {
+ "nodeId": "notify-preview",
+ "eventType": "partial",
+ "bindingId": "workflow.notify.send",
+ "messageContains": "Notification payload",
+ "detailsSubset": {
+ "target": "simulated-webhook",
+ "deliveryConfigured": true,
+ "inputItemCount": 1
+ }
+ },
+ {
+ "nodeId": "notify-preview",
+ "eventType": "completed",
+ "blockReasonCode": null,
+ "messageContains": "Notification send completed"
+ }
+]
diff --git a/backend/workflow/conformance/expected_events/missing-binding.json b/backend/workflow/conformance/expected_events/missing-binding.json
new file mode 100644
index 0000000..ba46eea
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/missing-binding.json
@@ -0,0 +1,14 @@
+[
+ {
+ "nodeId": "unsupported-export",
+ "eventType": "blocked",
+ "blockReasonCode": "missing_runtime_binding",
+ "messageContains": "No runtime binding registered",
+ "blockReasonDetailsSubset": {
+ "code": "missing_runtime_binding",
+ "node_id": "unsupported-export",
+ "kind": "action",
+ "capability": "summarize"
+ }
+ }
+]
diff --git a/backend/workflow/conformance/expected_events/missing-runtime-resource.json b/backend/workflow/conformance/expected_events/missing-runtime-resource.json
new file mode 100644
index 0000000..f828e4f
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/missing-runtime-resource.json
@@ -0,0 +1,14 @@
+[
+ {
+ "nodeId": "publish-turbopush",
+ "eventType": "blocked",
+ "blockReasonCode": "missing_turbopush_service",
+ "blockReasonCategory": "missing_runtime_resource",
+ "messageContains": "TurboPush local service is not configured",
+ "blockReasonDetailsSubset": {
+ "code": "missing_turbopush_service",
+ "node_id": "publish-turbopush",
+ "provider": "turbopush"
+ }
+ }
+]
diff --git a/backend/workflow/conformance/expected_events/missing-source-credential.json b/backend/workflow/conformance/expected_events/missing-source-credential.json
new file mode 100644
index 0000000..bb18ca6
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/missing-source-credential.json
@@ -0,0 +1,14 @@
+[
+ {
+ "nodeId": "source-jin10",
+ "eventType": "blocked",
+ "blockReasonCode": "missing_source_credential",
+ "blockReasonCategory": "missing_source_credential",
+ "messageContains": "required source credential",
+ "blockReasonDetailsSubset": {
+ "bindingId": "workflow.source.fetch",
+ "provider": "jin10",
+ "requiredCredentialKey": "jin10_api_token"
+ }
+ }
+]
diff --git a/backend/workflow/conformance/expected_events/missing-webhook-url.json b/backend/workflow/conformance/expected_events/missing-webhook-url.json
new file mode 100644
index 0000000..a729966
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/missing-webhook-url.json
@@ -0,0 +1,19 @@
+[
+ {
+ "nodeId": "notify-webhook",
+ "eventType": "blocked",
+ "blockReasonCode": "missing_delivery_projection",
+ "blockReasonCategory": "missing_config",
+ "messageContains": "configured webhook URL",
+ "blockReasonDetailsSubset": {
+ "code": "missing_delivery_projection",
+ "node_id": "notify-webhook",
+ "provider": "webhook",
+ "required_params": [
+ "evidencebatch_projection_api",
+ "delivery_projection",
+ "webhook_url"
+ ]
+ }
+ }
+]
diff --git a/backend/workflow/conformance/expected_events/permission-blocked.json b/backend/workflow/conformance/expected_events/permission-blocked.json
new file mode 100644
index 0000000..fe812a2
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/permission-blocked.json
@@ -0,0 +1,20 @@
+[
+ {
+ "nodeId": "source-jin10",
+ "eventType": "blocked",
+ "blockReasonCode": "fetch_permission_required",
+ "blockReasonDetailsSubset": {
+ "bindingId": "workflow.source.fetch",
+ "requiredPermission": "canFetchNetwork"
+ }
+ },
+ {
+ "nodeId": "notify-preview",
+ "eventType": "blocked",
+ "blockReasonCode": "send_permission_required",
+ "blockReasonDetailsSubset": {
+ "bindingId": "workflow.notify.send",
+ "requiredPermission": "canSendNotifications"
+ }
+ }
+]
diff --git a/backend/workflow/conformance/expected_events/webhook-real-delivery.json b/backend/workflow/conformance/expected_events/webhook-real-delivery.json
new file mode 100644
index 0000000..35384d3
--- /dev/null
+++ b/backend/workflow/conformance/expected_events/webhook-real-delivery.json
@@ -0,0 +1,29 @@
+[
+ {
+ "nodeId": "notify-webhook",
+ "eventType": "queued"
+ },
+ {
+ "nodeId": "notify-webhook",
+ "eventType": "started",
+ "messageContains": "Webhook delivery started"
+ },
+ {
+ "nodeId": "notify-webhook",
+ "eventType": "partial",
+ "bindingId": "workflow.notifier.webhook.send",
+ "messageContains": "Webhook delivery evidence emitted",
+ "detailsSubset": {
+ "deliveryAttempted": true,
+ "delivered": true,
+ "event": "workflow.evidence_batch.ready",
+ "payloadSchema": "workflow.webhook.evidence_batch.v1",
+ "itemCount": 1
+ }
+ },
+ {
+ "nodeId": "notify-webhook",
+ "eventType": "completed",
+ "messageContains": "Webhook delivery completed"
+ }
+]
diff --git a/backend/workflow/event_mirror.py b/backend/workflow/event_mirror.py
new file mode 100644
index 0000000..d3f06cc
--- /dev/null
+++ b/backend/workflow/event_mirror.py
@@ -0,0 +1,262 @@
+"""ODP/Redis mirror for workflow-run event evidence."""
+
+from __future__ import annotations
+
+import inspect
+import json
+import os
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+from backend.config import get_settings
+from backend.schemas.workflow import WorkflowNodeRunEvent
+
+SCHEMA_VERSION = 1
+WORKFLOW_EVENT_MIRROR_PROVIDER = "opencli-admin/workflow-run-event"
+DEFAULT_WORKFLOW_EVENT_STREAM = "odp.workflow_run.events"
+WorkflowEventMirrorBackend = Literal["memory", "redis"]
+
+_MEMORY_STREAMS: dict[str, list[tuple[str, dict[str, str]]]] = {}
+
+
+@dataclass(frozen=True)
+class WorkflowRunEventMirrorRecord:
+ """One stable workflow-run event fact mirrored to an ODP/Redis stream."""
+
+ workflow_id: str
+ workflow_run_id: str
+ trace_id: str
+ event_id: str
+ sequence: int
+ node_id: str
+ event_type: str
+ source_ts: str
+ payload: dict[str, Any]
+ schema_version: int = SCHEMA_VERSION
+ provider: str = WORKFLOW_EVENT_MIRROR_PROVIDER
+ ingest_mode: Literal["stream"] = "stream"
+ stable_facts: dict[str, Any] = field(default_factory=dict)
+
+ @classmethod
+ def from_event(cls, event: WorkflowNodeRunEvent) -> WorkflowRunEventMirrorRecord:
+ event_payload = event.model_dump(mode="json")
+ return cls(
+ workflow_id=event.workflowId,
+ workflow_run_id=event.workflowRunId,
+ trace_id=event.traceId,
+ event_id=event.id,
+ sequence=event.sequence,
+ node_id=event.nodeId,
+ event_type=event.eventType,
+ source_ts=event.createdAt,
+ payload={"event": event_payload},
+ stable_facts=_stable_facts(event_payload),
+ )
+
+ def to_wire(self) -> dict[str, Any]:
+ return {
+ "schema_version": self.schema_version,
+ "provider": self.provider,
+ "workflow_id": self.workflow_id,
+ "workflow_run_id": self.workflow_run_id,
+ "trace_id": self.trace_id,
+ "event_id": self.event_id,
+ "sequence": self.sequence,
+ "node_id": self.node_id,
+ "event_type": self.event_type,
+ "ingest_mode": self.ingest_mode,
+ "source_ts": self.source_ts,
+ "stable_facts": self.stable_facts,
+ "payload": self.payload,
+ }
+
+ @classmethod
+ def from_wire(cls, payload: dict[str, Any]) -> WorkflowRunEventMirrorRecord:
+ return cls(
+ schema_version=int(payload.get("schema_version", SCHEMA_VERSION)),
+ provider=str(payload.get("provider", "")),
+ workflow_id=str(payload.get("workflow_id", "")),
+ workflow_run_id=str(payload.get("workflow_run_id", "")),
+ trace_id=str(payload.get("trace_id", "")),
+ event_id=str(payload.get("event_id", "")),
+ sequence=int(payload.get("sequence", 0)),
+ node_id=str(payload.get("node_id", "")),
+ event_type=str(payload.get("event_type", "")),
+ ingest_mode="stream",
+ source_ts=str(payload.get("source_ts", "")),
+ stable_facts=_read_dict(payload.get("stable_facts")),
+ payload=_read_dict(payload.get("payload")),
+ )
+
+ def to_transcript_event(self) -> dict[str, Any]:
+ return _read_dict(self.payload.get("event"))
+
+
+async def publish_workflow_run_event_mirror(
+ events: list[WorkflowNodeRunEvent],
+ *,
+ backend: WorkflowEventMirrorBackend | None = None,
+ stream: str | None = None,
+) -> list[str]:
+ records = [WorkflowRunEventMirrorRecord.from_event(event) for event in events]
+ if not records:
+ return []
+
+ resolved_backend = backend or _mirror_backend()
+ resolved_stream = stream or _mirror_stream()
+ if resolved_backend == "redis":
+ return await _publish_to_redis(records, stream=resolved_stream)
+ return await _publish_to_memory(records, stream=resolved_stream)
+
+
+async def list_workflow_event_mirror_records(
+ run_id: str,
+ *,
+ backend: WorkflowEventMirrorBackend | None = None,
+ stream: str | None = None,
+) -> list[WorkflowRunEventMirrorRecord]:
+ resolved_backend = backend or _mirror_backend()
+ resolved_stream = stream or _mirror_stream()
+ records = (
+ await _read_redis_records(resolved_stream)
+ if resolved_backend == "redis"
+ else _read_memory_records(resolved_stream)
+ )
+ return [record for record in records if record.workflow_run_id == run_id]
+
+
+async def list_workflow_event_mirror_transcript(
+ run_id: str,
+ *,
+ backend: WorkflowEventMirrorBackend | None = None,
+ stream: str | None = None,
+) -> list[dict[str, Any]]:
+ records = await list_workflow_event_mirror_records(
+ run_id,
+ backend=backend,
+ stream=stream,
+ )
+ return [record.to_transcript_event() for record in records]
+
+
+def reset_memory_workflow_event_mirror() -> None:
+ _MEMORY_STREAMS.clear()
+
+
+async def _publish_to_memory(
+ records: list[WorkflowRunEventMirrorRecord],
+ *,
+ stream: str,
+) -> list[str]:
+ entries = _MEMORY_STREAMS.setdefault(stream, [])
+ ids: list[str] = []
+ for record in records:
+ entry_id = f"memory-{len(entries) + 1}"
+ entries.append((entry_id, {"event": json.dumps(record.to_wire(), sort_keys=True)}))
+ ids.append(entry_id)
+ return ids
+
+
+def _read_memory_records(stream: str) -> list[WorkflowRunEventMirrorRecord]:
+ return [_record_from_fields(fields) for _, fields in _MEMORY_STREAMS.get(stream, [])]
+
+
+async def _publish_to_redis(
+ records: list[WorkflowRunEventMirrorRecord],
+ *,
+ stream: str,
+) -> list[str]:
+ client = _redis_client()
+ ids: list[str] = []
+ try:
+ for record in records:
+ entry_id = await client.xadd(
+ stream,
+ {"event": json.dumps(record.to_wire(), sort_keys=True)},
+ )
+ ids.append(str(entry_id))
+ finally:
+ await _close_redis(client)
+ return ids
+
+
+async def _read_redis_records(stream: str) -> list[WorkflowRunEventMirrorRecord]:
+ client = _redis_client()
+ try:
+ entries = await client.xrange(stream, min="-", max="+")
+ finally:
+ await _close_redis(client)
+ return [_record_from_fields(fields) for _, fields in entries]
+
+
+def _record_from_fields(fields: dict[Any, Any]) -> WorkflowRunEventMirrorRecord:
+ raw = fields.get("event") or fields.get(b"event")
+ if isinstance(raw, bytes):
+ raw = raw.decode("utf-8")
+ if not isinstance(raw, str):
+ raise ValueError("workflow event mirror entry missing 'event' field")
+ payload = json.loads(raw)
+ if not isinstance(payload, dict):
+ raise ValueError("workflow event mirror entry must be a JSON object")
+ return WorkflowRunEventMirrorRecord.from_wire(payload)
+
+
+def _redis_client() -> Any:
+ import redis.asyncio as aioredis # type: ignore[import-untyped]
+
+ return aioredis.from_url(_mirror_redis_url(), decode_responses=True)
+
+
+async def _close_redis(client: Any) -> None:
+ closer = getattr(client, "aclose", None) or getattr(client, "close", None)
+ if closer is None:
+ return
+ result = closer()
+ if inspect.isawaitable(result):
+ await result
+
+
+def _mirror_backend() -> WorkflowEventMirrorBackend:
+ value = os.getenv("WORKFLOW_EVENT_MIRROR_BACKEND", "memory").strip().lower()
+ return "redis" if value == "redis" else "memory"
+
+
+def _mirror_stream() -> str:
+ return os.getenv("WORKFLOW_EVENT_MIRROR_STREAM", DEFAULT_WORKFLOW_EVENT_STREAM)
+
+
+def _mirror_redis_url() -> str:
+ return (
+ os.getenv("WORKFLOW_EVENT_MIRROR_REDIS_URL")
+ or os.getenv("ODP_REDIS_URL")
+ or os.getenv("REDIS_URL")
+ or get_settings().redis_url
+ )
+
+
+def _stable_facts(event: dict[str, Any]) -> dict[str, Any]:
+ block_reason = _read_dict(event.get("blockReason"))
+ details = _read_dict(event.get("details"))
+ block_details = _read_dict(block_reason.get("details"))
+ return {
+ "nodeId": event.get("nodeId"),
+ "eventType": event.get("eventType"),
+ "bindingId": details.get("bindingId") or block_details.get("bindingId"),
+ "blockReasonCode": block_reason.get("code"),
+ }
+
+
+def _read_dict(value: Any) -> dict[str, Any]:
+ return value if isinstance(value, dict) else {}
+
+
+__all__ = [
+ "DEFAULT_WORKFLOW_EVENT_STREAM",
+ "SCHEMA_VERSION",
+ "WORKFLOW_EVENT_MIRROR_PROVIDER",
+ "WorkflowRunEventMirrorRecord",
+ "list_workflow_event_mirror_records",
+ "list_workflow_event_mirror_transcript",
+ "publish_workflow_run_event_mirror",
+ "reset_memory_workflow_event_mirror",
+]
diff --git a/backend/workflow/opencli_hda_tracer.py b/backend/workflow/opencli_hda_tracer.py
index 29ce724..1a473b0 100644
--- a/backend/workflow/opencli_hda_tracer.py
+++ b/backend/workflow/opencli_hda_tracer.py
@@ -36,7 +36,15 @@
WorkflowRunStartRequest,
WorkflowRunStatus,
)
+from backend.workflow.block_reasons import (
+ FETCH_PERMISSION_REQUIRED,
+ MISSING_DELIVERY_PROJECTION,
+ MISSING_SOURCE_CREDENTIAL,
+ SEND_PERMISSION_REQUIRED,
+ SOURCE_OUTPUT_REQUIRED,
+)
from backend.workflow.compiler import INTERNAL_ID_SEPARATOR, compile_workflow_project
+from backend.workflow.event_mirror import publish_workflow_run_event_mirror
from backend.workflow.fleet_inventory import match_workflow_fleet_capability
from backend.workflow.realtime_market_executor import (
OKX_MARKET_TICKER_SNAPSHOT_EXECUTOR,
@@ -45,18 +53,27 @@
)
from backend.workflow.runtime_registry import (
EXTERNAL_TOOL_BINDING_ID,
+ INBOX_STORE_BINDING_ID,
MERGE_BINDING_ID,
NORMALIZE_BINDING_ID,
+ NOTIFY_SEND_BINDING_ID,
OPENCLI_FUNCTION_ID,
OPENCLI_WORKER,
RECORD_ACCEPTANCE_BINDING_ID,
RECORD_SINK_BINDING_ID,
+ ROUTER_ROUTE_BINDING_ID,
+ SOURCE_FETCH_BINDING_ID,
+ WEBHOOK_NOTIFY_BINDING_ID,
)
from backend.workflow.turbopush_executor import (
TurboPushPublishError,
execute_turbopush_publish,
)
from backend.workflow.turbopush_runtime import TURBOPUSH_BINDING_ID
+from backend.workflow.webhook_delivery import (
+ WorkflowWebhookDeliveryError,
+ execute_workflow_webhook_delivery,
+)
@dataclass
@@ -323,6 +340,19 @@ async def start_workflow_run(
emitter.emit(node, "completed", message="Bound source records completed")
continue
+ if _is_workflow_source_fetch_node(node):
+ reason = _source_fetch_block_reason(node, body.project.agentPermissions)
+ emitter.emit(node, "started", message="Workflow source fetch binding started")
+ emitter.emit(
+ node,
+ "blocked",
+ message=reason.message,
+ block_reason=reason,
+ )
+ if package_parent_id:
+ blocked_by_package.setdefault(package_parent_id, []).append(reason)
+ continue
+
if _is_turbopush_publish_node(node):
if not body.project.agentPermissions.canSendNotifications:
reason = WorkflowRunBlockReason(
@@ -377,16 +407,48 @@ async def start_workflow_run(
emitter.emit(node, "completed", message="TurboPush publish completed")
continue
- if _is_first_loop_native_node(node):
- details, output_items = await _execute_native_node(
+ if _is_workflow_notify_node(node) or _is_webhook_notify_node(node):
+ reason = _notify_send_block_reason(
node,
- outputs_by_node,
- run_id,
- workflow_id=body.project.id,
- session=session,
- runtime_nodes_by_id=runtime_nodes_by_id,
- materialized_source_tasks=materialized_source_tasks,
+ body.project.agentPermissions,
+ outputs_by_node=outputs_by_node,
)
+ if reason is not None:
+ emitter.emit(node, "started", message="Workflow notification binding started")
+ emitter.emit(
+ node,
+ "blocked",
+ message=reason.message,
+ block_reason=reason,
+ )
+ continue
+
+ if _is_first_loop_native_node(node):
+ try:
+ details, output_items = await _execute_native_node(
+ node,
+ outputs_by_node,
+ run_id,
+ workflow_id=body.project.id,
+ session=session,
+ runtime_nodes_by_id=runtime_nodes_by_id,
+ materialized_source_tasks=materialized_source_tasks,
+ )
+ except WorkflowWebhookDeliveryError as exc:
+ reason = WorkflowRunBlockReason(
+ code=exc.code,
+ message=exc.message,
+ source="workflow_webhook_delivery",
+ details=exc.details,
+ )
+ emitter.emit(
+ node,
+ "failed",
+ message=exc.message,
+ block_reason=reason,
+ details=reason.details,
+ )
+ continue
outputs_by_node[node.id] = output_items
emitter.emit(node, "started", message=_native_node_started_message(node))
if _binding_id(node) == EXTERNAL_TOOL_BINDING_ID:
@@ -438,11 +500,7 @@ async def start_workflow_run(
batch = batch.model_copy(update={"itemCount": len(output_items)})
dispatch_trace_details = {
**({"fleetMatch": fleet_match_details} if fleet_match_details else {}),
- **(
- {"agentDispatch": agent_dispatch_details}
- if agent_dispatch_details
- else {}
- ),
+ **({"agentDispatch": agent_dispatch_details} if agent_dispatch_details else {}),
}
emitter.emit(
node,
@@ -458,10 +516,7 @@ async def start_workflow_run(
if agent_dispatch_details and agent_dispatch_details.get("success") is False:
reason = WorkflowRunBlockReason(
code="fleet_agent_dispatch_failed",
- message=str(
- agent_dispatch_details.get("error")
- or "Fleet agent dispatch failed"
- ),
+ message=str(agent_dispatch_details.get("error") or "Fleet agent dispatch failed"),
source="workflow_fleet",
details={
"adapterTaskId": dispatch.taskId,
@@ -658,44 +713,44 @@ async def _store_workflow_run(
) -> None:
stored = _StoredWorkflowRun(request, projection, list(events))
_RUNS[run_id] = stored
- if session is None:
- return
-
- row = await session.get(WorkflowRunRow, run_id)
- if row is None:
- row = WorkflowRunRow(id=run_id)
- session.add(row)
-
- row.workflow_id = projection.workflowId
- row.trace_id = projection.traceId
- row.status = projection.status
- row.valid = projection.valid
- row.package_node_id = projection.packageNodeId
- row.request = request.model_dump(mode="json")
- row.projection = projection.model_dump(mode="json")
-
- existing_events = (
- await session.execute(
- select(WorkflowRunEventRow).where(WorkflowRunEventRow.run_id == run_id)
- )
- ).scalars()
- for event_row in existing_events:
- await session.delete(event_row)
-
- for event in events:
- session.add(
- WorkflowRunEventRow(
- run_id=run_id,
- workflow_id=event.workflowId,
- trace_id=event.traceId,
- event_id=event.id,
- node_id=event.nodeId,
- sequence=event.sequence,
- event_type=event.eventType,
- payload=event.model_dump(mode="json"),
+ if session is not None:
+ row = await session.get(WorkflowRunRow, run_id)
+ if row is None:
+ row = WorkflowRunRow(id=run_id)
+ session.add(row)
+
+ row.workflow_id = projection.workflowId
+ row.trace_id = projection.traceId
+ row.status = projection.status
+ row.valid = projection.valid
+ row.package_node_id = projection.packageNodeId
+ row.request = request.model_dump(mode="json")
+ row.projection = projection.model_dump(mode="json")
+
+ existing_events = (
+ await session.execute(
+ select(WorkflowRunEventRow).where(WorkflowRunEventRow.run_id == run_id)
)
- )
- await session.flush()
+ ).scalars()
+ for event_row in existing_events:
+ await session.delete(event_row)
+
+ for event in events:
+ session.add(
+ WorkflowRunEventRow(
+ run_id=run_id,
+ workflow_id=event.workflowId,
+ trace_id=event.traceId,
+ event_id=event.id,
+ node_id=event.nodeId,
+ sequence=event.sequence,
+ event_type=event.eventType,
+ payload=event.model_dump(mode="json"),
+ )
+ )
+ await session.flush()
+
+ await publish_workflow_run_event_mirror(events)
async def _load_workflow_run(
@@ -1163,6 +1218,18 @@ def _is_turbopush_publish_node(node: CompiledWorkflowNode) -> bool:
return isinstance(binding, dict) and binding.get("binding_id") == TURBOPUSH_BINDING_ID
+def _is_workflow_source_fetch_node(node: CompiledWorkflowNode) -> bool:
+ return _binding_id(node) == SOURCE_FETCH_BINDING_ID
+
+
+def _is_workflow_notify_node(node: CompiledWorkflowNode) -> bool:
+ return _binding_id(node) == NOTIFY_SEND_BINDING_ID
+
+
+def _is_webhook_notify_node(node: CompiledWorkflowNode) -> bool:
+ return _binding_id(node) == WEBHOOK_NOTIFY_BINDING_ID
+
+
def _is_first_loop_native_node(node: CompiledWorkflowNode) -> bool:
binding = node.runtime.get("binding")
if not isinstance(binding, dict):
@@ -1170,8 +1237,12 @@ def _is_first_loop_native_node(node: CompiledWorkflowNode) -> bool:
return binding.get("binding_id") in {
NORMALIZE_BINDING_ID,
MERGE_BINDING_ID,
+ ROUTER_ROUTE_BINDING_ID,
RECORD_ACCEPTANCE_BINDING_ID,
RECORD_SINK_BINDING_ID,
+ INBOX_STORE_BINDING_ID,
+ NOTIFY_SEND_BINDING_ID,
+ WEBHOOK_NOTIFY_BINDING_ID,
EXTERNAL_TOOL_BINDING_ID,
}
@@ -1321,6 +1392,23 @@ async def _execute_native_node(
},
merged,
)
+ if binding_id == ROUTER_ROUTE_BINDING_ID:
+ binding = _read_dict(node.runtime.get("binding"))
+ binding_input = _read_dict(binding.get("input"))
+ expression = _read_string(binding_input.get("expression")) or "true"
+ routed = _route_runtime_items(node, input_items, run_id, expression=expression)
+ return (
+ {
+ "bindingId": binding_id,
+ "expression": expression,
+ "inputType": binding_input.get("inputPort", "recordCandidate[]"),
+ "outputType": binding_input.get("outputPort", "recordCandidate[]"),
+ "inputCandidateCount": len(input_items),
+ "routedCandidateCount": len(routed),
+ "lineage": _lineage_pointer(node),
+ },
+ routed,
+ )
if binding_id == RECORD_ACCEPTANCE_BINDING_ID:
binding = _read_dict(node.runtime.get("binding"))
binding_input = _read_dict(binding.get("input"))
@@ -1343,15 +1431,20 @@ async def _execute_native_node(
},
accepted,
)
- if binding_id == RECORD_SINK_BINDING_ID:
+ if binding_id in {RECORD_SINK_BINDING_ID, INBOX_STORE_BINDING_ID}:
binding = _read_dict(node.runtime.get("binding"))
binding_input = _read_dict(binding.get("input"))
+ target = (
+ _read_string(binding_input.get("target"))
+ or _read_string(binding_input.get("queue"))
+ or "records"
+ )
stored_refs, skipped_count = await _store_record_sink_outputs(
node,
input_items,
run_id=run_id,
workflow_id=workflow_id,
- target=_read_string(binding_input.get("target")) or "records",
+ target=target,
session=session,
runtime_nodes_by_id=runtime_nodes_by_id or {},
materialized_source_tasks=materialized_source_tasks or {},
@@ -1359,7 +1452,7 @@ async def _execute_native_node(
return (
{
"bindingId": binding_id,
- "target": binding_input.get("target", "records"),
+ "target": target,
"writeMode": binding_input.get("writeMode", "append"),
"inputRecordCount": len(input_items),
"storedRecordCount": len(stored_refs),
@@ -1369,6 +1462,39 @@ async def _execute_native_node(
},
input_items,
)
+ if binding_id == NOTIFY_SEND_BINDING_ID:
+ binding = _read_dict(node.runtime.get("binding"))
+ binding_input = _read_dict(binding.get("input"))
+ return (
+ {
+ "bindingId": binding_id,
+ "notifierType": binding_input.get("notifier_type", "workflow"),
+ "target": binding_input.get("target", "workflow"),
+ "template": binding_input.get("template", "brief"),
+ "deliveryConfigured": binding_input.get("delivery_configured", False),
+ "inputItemCount": len(input_items),
+ "lineage": _lineage_pointer(node),
+ },
+ input_items,
+ )
+ if binding_id == WEBHOOK_NOTIFY_BINDING_ID:
+ binding = _read_dict(node.runtime.get("binding"))
+ binding_input = _read_dict(binding.get("input"))
+ delivery = await execute_workflow_webhook_delivery(
+ binding_input,
+ input_items,
+ workflow_id=workflow_id,
+ run_id=run_id,
+ node_id=node.id,
+ )
+ return (
+ {
+ "bindingId": binding_id,
+ **delivery,
+ "lineage": _lineage_pointer(node),
+ },
+ input_items,
+ )
if binding_id == EXTERNAL_TOOL_BINDING_ID:
binding = _read_dict(node.runtime.get("binding"))
binding_input = _read_dict(binding.get("input"))
@@ -1784,16 +1910,273 @@ def _candidate_has_lineage(item: dict[str, Any]) -> bool:
return bool(_read_dict_list(item.get("lineage")))
+def _route_runtime_items(
+ node: CompiledWorkflowNode,
+ input_items: list[dict[str, Any]],
+ run_id: str,
+ *,
+ expression: str,
+) -> list[dict[str, Any]]:
+ return [
+ _append_lineage(item, node, step="route", run_id=run_id)
+ for item in input_items
+ if _matches_route_expression(item, expression)
+ ]
+
+
+def _matches_route_expression(item: dict[str, Any], expression: str) -> bool:
+ normalized_expression = expression.strip()
+ if not normalized_expression or normalized_expression.lower() == "true":
+ return True
+ if normalized_expression.lower() == "false":
+ return False
+
+ or_terms = [term.strip() for term in normalized_expression.split("||")]
+ if len(or_terms) > 1:
+ return any(_matches_route_expression(item, term) for term in or_terms)
+
+ and_terms = [term.strip() for term in normalized_expression.split("&&")]
+ if len(and_terms) > 1:
+ return all(_matches_route_expression(item, term) for term in and_terms)
+
+ for operator in (">=", "<=", "===", "==", ">", "<"):
+ if operator not in normalized_expression:
+ continue
+ left, right = [part.strip() for part in normalized_expression.split(operator, 1)]
+ if not left.startswith("item."):
+ return True
+ value = _item_value(item, left.removeprefix("item."))
+ expected = _parse_expression_literal(right)
+ if operator in {"===", "=="}:
+ return value == expected
+ if not isinstance(value, int | float) or not isinstance(expected, int | float):
+ return False
+ if operator == ">=":
+ return value >= expected
+ if operator == "<=":
+ return value <= expected
+ if operator == ">":
+ return value > expected
+ if operator == "<":
+ return value < expected
+
+ if normalized_expression.startswith("item."):
+ return bool(_item_value(item, normalized_expression.removeprefix("item.")))
+ return True
+
+
+def _parse_expression_literal(raw: str) -> object:
+ value = raw.strip().strip('"').strip("'")
+ if value == "true":
+ return True
+ if value == "false":
+ return False
+ try:
+ return float(value) if "." in value else int(value)
+ except ValueError:
+ return value
+
+
+def _item_value(item: dict[str, Any], path: str) -> object:
+ raw = _read_dict(item.get("raw"))
+ normalized = _read_dict(item.get("normalizedData"))
+ for source in (raw, normalized, item):
+ value: object = source
+ for part in path.split("."):
+ if not isinstance(value, dict) or part not in value:
+ value = None
+ break
+ value = value[part]
+ if value is not None:
+ return value
+ return None
+
+
+def _required_source_credential_key(binding_input: dict[str, Any]) -> str | None:
+ config = _read_dict(binding_input.get("adapterConfig"))
+ params = _read_dict(binding_input.get("params"))
+ for source in (params, config, binding_input):
+ for field in (
+ "requiredCredentialKey",
+ "requiredCredential",
+ "credentialKey",
+ "credentialName",
+ ):
+ value = _read_string(source.get(field))
+ if value:
+ return value
+
+ required = source.get("requiresCredential")
+ if isinstance(required, str) and required.strip():
+ return required.strip()
+ if required is True:
+ return "default"
+ return None
+
+
+def _source_credential_configured(binding_input: dict[str, Any]) -> bool:
+ config = _read_dict(binding_input.get("adapterConfig"))
+ params = _read_dict(binding_input.get("params"))
+ for source in (params, config, binding_input):
+ if source.get("credentialConfigured") is True:
+ return True
+ for field in ("credentialRef", "credentialId", "authRef", "secretRef"):
+ if _read_string(source.get(field)):
+ return True
+ auth = source.get("auth")
+ if isinstance(auth, dict) and auth:
+ return True
+ return False
+
+
+def _source_fetch_block_reason(
+ node: CompiledWorkflowNode,
+ permissions: object,
+) -> WorkflowRunBlockReason:
+ binding = _read_dict(node.runtime.get("binding"))
+ binding_input = _read_dict(binding.get("input"))
+ if not bool(getattr(permissions, "canFetchNetwork", False)):
+ return WorkflowRunBlockReason(
+ code=FETCH_PERMISSION_REQUIRED,
+ message=(
+ "Workflow source fetch is bound, but agentPermissions.canFetchNetwork is false."
+ ),
+ source="workflow_permissions",
+ details={
+ "nodeId": node.id,
+ "bindingId": SOURCE_FETCH_BINDING_ID,
+ "requiredPermission": "canFetchNetwork",
+ },
+ )
+
+ credential_key = _required_source_credential_key(binding_input)
+ if credential_key and not _source_credential_configured(binding_input):
+ return WorkflowRunBlockReason(
+ code=MISSING_SOURCE_CREDENTIAL,
+ message=(
+ "Workflow source fetch is bound, but the required source "
+ "credential is not configured."
+ ),
+ source="workflow_source_credentials",
+ details={
+ "nodeId": node.id,
+ "bindingId": SOURCE_FETCH_BINDING_ID,
+ "provider": binding_input.get("provider"),
+ "channelType": binding_input.get("channelType"),
+ "requiredCredentialKey": credential_key,
+ },
+ )
+
+ live_mode = _read_string(binding_input.get("liveMode")) or "live"
+ if live_mode in {"fixture", "mock"}:
+ return WorkflowRunBlockReason(
+ code=SOURCE_OUTPUT_REQUIRED,
+ message=(
+ "Fixture/mock source fetch requires sourceOutputs, fixtureItems, "
+ "or bound source records before downstream nodes can run."
+ ),
+ source="workflow_source",
+ details={
+ "nodeId": node.id,
+ "bindingId": SOURCE_FETCH_BINDING_ID,
+ "liveMode": live_mode,
+ },
+ )
+
+ return WorkflowRunBlockReason(
+ code="live_source_executor_pending",
+ message=(
+ "Workflow source fetch is bound, but this source provider does not "
+ "yet have a live executor in the workflow run service."
+ ),
+ source="workflow_source",
+ details={
+ "nodeId": node.id,
+ "bindingId": SOURCE_FETCH_BINDING_ID,
+ "provider": binding_input.get("provider"),
+ "channelType": binding_input.get("channelType"),
+ },
+ )
+
+
+def _notify_send_block_reason(
+ node: CompiledWorkflowNode,
+ permissions: object,
+ *,
+ outputs_by_node: dict[str, list[dict[str, Any]]] | None = None,
+) -> WorkflowRunBlockReason | None:
+ binding = _read_dict(node.runtime.get("binding"))
+ binding_input = _read_dict(binding.get("input"))
+ binding_id = _binding_id(node) or NOTIFY_SEND_BINDING_ID
+ if not bool(getattr(permissions, "canSendNotifications", False)):
+ return WorkflowRunBlockReason(
+ code=SEND_PERMISSION_REQUIRED,
+ message=(
+ "Workflow notification is bound, but "
+ "agentPermissions.canSendNotifications is false."
+ ),
+ source="workflow_permissions",
+ details={
+ "nodeId": node.id,
+ "bindingId": binding_id,
+ "requiredPermission": "canSendNotifications",
+ },
+ )
+ if not bool(binding_input.get("delivery_configured")):
+ return WorkflowRunBlockReason(
+ code=MISSING_DELIVERY_PROJECTION,
+ message=(
+ "Workflow notification is bound, but delivery requires a "
+ "configured notifier target."
+ ),
+ source="workflow_notifier",
+ details={
+ "nodeId": node.id,
+ "bindingId": binding_id,
+ "required_params": ["webhook_url"],
+ },
+ )
+ if binding_id == WEBHOOK_NOTIFY_BINDING_ID and not _upstream_outputs(
+ node,
+ outputs_by_node or {},
+ ):
+ return WorkflowRunBlockReason(
+ code=MISSING_DELIVERY_PROJECTION,
+ message=(
+ "Webhook delivery is bound, but EvidenceBatch/resource "
+ "projection is not available."
+ ),
+ source="workflow_webhook_delivery",
+ details={
+ "nodeId": node.id,
+ "bindingId": WEBHOOK_NOTIFY_BINDING_ID,
+ "required_params": [
+ "evidencebatch_projection_api",
+ "delivery_projection",
+ ],
+ },
+ )
+ return None
+
+
def _native_node_started_message(node: CompiledWorkflowNode) -> str:
binding_id = _binding_id(node)
if binding_id == NORMALIZE_BINDING_ID:
return "Normalize transform started"
if binding_id == MERGE_BINDING_ID:
return "Merge node started"
+ if binding_id == ROUTER_ROUTE_BINDING_ID:
+ return "Router node started"
if binding_id == RECORD_ACCEPTANCE_BINDING_ID:
return "Record acceptance gate started"
if binding_id == RECORD_SINK_BINDING_ID:
return "Record sink started"
+ if binding_id == INBOX_STORE_BINDING_ID:
+ return "Inbox store started"
+ if binding_id == NOTIFY_SEND_BINDING_ID:
+ return "Notification send started"
+ if binding_id == WEBHOOK_NOTIFY_BINDING_ID:
+ return "Webhook delivery started"
if binding_id == EXTERNAL_TOOL_BINDING_ID:
return "OpenCLI Tool Capability started"
return "Native workflow node started"
@@ -1805,10 +2188,18 @@ def _native_node_partial_message(node: CompiledWorkflowNode) -> str:
return "Record Candidates projected"
if binding_id == MERGE_BINDING_ID:
return "Candidate streams merged with lineage"
+ if binding_id == ROUTER_ROUTE_BINDING_ID:
+ return "Candidates routed with lineage"
if binding_id == RECORD_ACCEPTANCE_BINDING_ID:
return "Record Candidates accepted as Records"
if binding_id == RECORD_SINK_BINDING_ID:
return "Accepted Records stored through Record Sink boundary"
+ if binding_id == INBOX_STORE_BINDING_ID:
+ return "Items stored through Inbox boundary"
+ if binding_id == NOTIFY_SEND_BINDING_ID:
+ return "Notification payload projected"
+ if binding_id == WEBHOOK_NOTIFY_BINDING_ID:
+ return "Webhook delivery evidence emitted"
if binding_id == EXTERNAL_TOOL_BINDING_ID:
return "OpenCLI Tool Capability emitted output"
return "Native workflow node emitted trace evidence"
@@ -1820,10 +2211,18 @@ def _native_node_completed_message(node: CompiledWorkflowNode) -> str:
return "Normalize transform completed"
if binding_id == MERGE_BINDING_ID:
return "Merge node completed"
+ if binding_id == ROUTER_ROUTE_BINDING_ID:
+ return "Router node completed"
if binding_id == RECORD_ACCEPTANCE_BINDING_ID:
return "Record acceptance gate completed"
if binding_id == RECORD_SINK_BINDING_ID:
return "Record sink completed"
+ if binding_id == INBOX_STORE_BINDING_ID:
+ return "Inbox store completed"
+ if binding_id == NOTIFY_SEND_BINDING_ID:
+ return "Notification send completed"
+ if binding_id == WEBHOOK_NOTIFY_BINDING_ID:
+ return "Webhook delivery completed"
if binding_id == EXTERNAL_TOOL_BINDING_ID:
return "OpenCLI Tool Capability completed"
return "Native workflow node completed"
diff --git a/backend/workflow/runtime_contracts.py b/backend/workflow/runtime_contracts.py
new file mode 100644
index 0000000..93afde9
--- /dev/null
+++ b/backend/workflow/runtime_contracts.py
@@ -0,0 +1,307 @@
+"""Stable workflow runtime node I/O contract declarations."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+RuntimeIOContractStatus = Literal[
+ "executable",
+ "dispatch_only",
+ "projection_only",
+ "blocked_until_preconditions",
+]
+
+
+@dataclass(frozen=True)
+class RuntimeIOContract:
+ binding_id: str
+ status: RuntimeIOContractStatus
+ input_ports: tuple[tuple[str, str], ...]
+ output_ports: tuple[tuple[str, str], ...]
+ input_params: tuple[str, ...]
+ output_artifacts: tuple[str, ...]
+ permission_gate: tuple[str, ...]
+ config_gate: tuple[str, ...]
+ event_shape: tuple[str, ...]
+ fixture_coverage: tuple[str, ...]
+ real_webhook_delivery: bool = False
+
+ def to_manifest(self) -> dict[str, object]:
+ return {
+ "schemaVersion": 1,
+ "bindingId": self.binding_id,
+ "status": self.status,
+ "inputShape": {
+ "ports": [_port(name, type_) for name, type_ in self.input_ports],
+ "params": list(self.input_params),
+ },
+ "outputShape": {
+ "ports": [_port(name, type_) for name, type_ in self.output_ports],
+ "artifacts": list(self.output_artifacts),
+ },
+ "permissionGate": {
+ "required": list(self.permission_gate),
+ },
+ "configGate": {
+ "required": list(self.config_gate),
+ },
+ "eventShape": {
+ "events": list(self.event_shape),
+ },
+ "fixtureCoverage": {
+ "cases": list(self.fixture_coverage),
+ },
+ "certification": {
+ "realNodeIoContract": True,
+ "realWebhookDelivery": self.real_webhook_delivery,
+ },
+ "canvas": {
+ "exposeResourceInternals": False,
+ },
+ }
+
+
+RUNTIME_IO_CONTRACTS: dict[str, RuntimeIOContract] = {
+ "workflow.demand-draft.patch": RuntimeIOContract(
+ binding_id="workflow.demand-draft.patch",
+ status="projection_only",
+ input_ports=(("in", "collectionNeed"),),
+ output_ports=(("patch", "workflowPatch"),),
+ input_params=("text", "locale"),
+ output_artifacts=("workflowPatch", "compilePreview"),
+ permission_gate=("canvas_review_required",),
+ config_gate=("capability_catalog",),
+ event_shape=("patch_preview", "compile_preview"),
+ fixture_coverage=("workflow-capabilities-api",),
+ ),
+ "workflow.trigger.schedule_tick": RuntimeIOContract(
+ binding_id="workflow.trigger.schedule_tick",
+ status="executable",
+ input_ports=(),
+ output_ports=(("tick", "trigger"),),
+ input_params=("interval", "timezone", "enabled"),
+ output_artifacts=("workflowRunTrigger",),
+ permission_gate=(),
+ config_gate=(),
+ event_shape=("queued", "started", "completed"),
+ fixture_coverage=("workflow-capabilities-api", "workflow-run-default-node"),
+ ),
+ "workflow.source-pool.parallel-fanout": RuntimeIOContract(
+ binding_id="workflow.source-pool.parallel-fanout",
+ status="executable",
+ input_ports=(("in", "trigger"),),
+ output_ports=(("out", "trigger"),),
+ input_params=("sourceCount", "sourceGroups", "fanout"),
+ output_artifacts=("sourceFanoutPlan",),
+ permission_gate=(),
+ config_gate=("source_slots_present",),
+ event_shape=("partial:sourceCount", "completed"),
+ fixture_coverage=("workflow-capabilities-api", "opencli-hda-trace-api"),
+ ),
+ "iii.collector-opencli.snapshot": RuntimeIOContract(
+ binding_id="iii.collector-opencli.snapshot",
+ status="dispatch_only",
+ input_ports=(("in", "trigger"),),
+ output_ports=(("out", "items[]"),),
+ input_params=("site", "command", "args", "format"),
+ output_artifacts=("batch_ready", "items[]"),
+ permission_gate=("canFetchNetwork",),
+ config_gate=("site", "command", "opencli_channel"),
+ event_shape=("batch_ready", "partial:itemCount", "completed"),
+ fixture_coverage=("happy-path", "sse-parity", "odp-redis-mirror"),
+ ),
+ "workflow.source.fetch": RuntimeIOContract(
+ binding_id="workflow.source.fetch",
+ status="blocked_until_preconditions",
+ input_ports=(("in", "trigger"),),
+ output_ports=(("items", "items[]"),),
+ input_params=("provider", "channelType", "liveMode", "sourceId"),
+ output_artifacts=("sourceOutputs", "fixtureItems", "boundSourceRecords"),
+ permission_gate=("canFetchNetwork",),
+ config_gate=("sourceOutputs_or_fixtureItems_or_boundSourceRecords", "sourceCredential?"),
+ event_shape=("partial:itemCount", "blocked:source_output_required", "completed"),
+ fixture_coverage=(
+ "happy-path",
+ "permission-blocked",
+ "missing-source-credential",
+ ),
+ ),
+ "workflow.collection-output.items": RuntimeIOContract(
+ binding_id="workflow.collection-output.items",
+ status="executable",
+ input_ports=(("in", "recordCandidate[]"),),
+ output_ports=(("out", "storedItems[]"),),
+ input_params=("queue", "archive"),
+ output_artifacts=("runTraceItems",),
+ permission_gate=(),
+ config_gate=("run_trace",),
+ event_shape=("partial:itemCount", "completed"),
+ fixture_coverage=("workflow-capabilities-api", "opencli-hda-trace-api"),
+ ),
+ "workflow.transform.normalize": RuntimeIOContract(
+ binding_id="workflow.transform.normalize",
+ status="executable",
+ input_ports=(("in", "items[]"),),
+ output_ports=(("out", "recordCandidate[]"),),
+ input_params=("language", "preserveSourceRefs"),
+ output_artifacts=("recordCandidate[]",),
+ permission_gate=(),
+ config_gate=(),
+ event_shape=("partial:recordCandidateCount", "completed"),
+ fixture_coverage=("happy-path", "sse-parity", "odp-redis-mirror"),
+ ),
+ "workflow.flow.merge": RuntimeIOContract(
+ binding_id="workflow.flow.merge",
+ status="executable",
+ input_ports=(("in1", "recordCandidate[]"), ("in2", "recordCandidate[]")),
+ output_ports=(("out", "recordCandidate[]"),),
+ input_params=("strategy", "preserveLineage"),
+ output_artifacts=("recordCandidate[]",),
+ permission_gate=(),
+ config_gate=("typed_port_contract_registered",),
+ event_shape=("partial:mergedCandidateCount", "completed"),
+ fixture_coverage=("workflow-capabilities-api",),
+ ),
+ "workflow.router.route": RuntimeIOContract(
+ binding_id="workflow.router.route",
+ status="executable",
+ input_ports=(("in", "recordCandidate[]"),),
+ output_ports=(("out", "recordCandidate[]"),),
+ input_params=("expression", "mode"),
+ output_artifacts=("recordCandidate[]",),
+ permission_gate=(),
+ config_gate=(),
+ event_shape=("partial:routedCandidateCount", "completed"),
+ fixture_coverage=("happy-path", "sse-parity", "odp-redis-mirror"),
+ ),
+ "workflow.gate.record-acceptance": RuntimeIOContract(
+ binding_id="workflow.gate.record-acceptance",
+ status="executable",
+ input_ports=(("candidates", "recordCandidate[]"),),
+ output_ports=(("records", "record[]"),),
+ input_params=("mode", "schema", "dedupe", "lineageRequired", "minQuality"),
+ output_artifacts=("record[]", "reviewRequiredCount"),
+ permission_gate=("record_acceptance_policy",),
+ config_gate=("record_schema_registry",),
+ event_shape=("partial:acceptedRecordCount", "partial:reviewRequiredCount", "completed"),
+ fixture_coverage=("workflow-capabilities-api",),
+ ),
+ "workflow.record-sink.records": RuntimeIOContract(
+ binding_id="workflow.record-sink.records",
+ status="executable",
+ input_ports=(("records", "record[]"),),
+ output_ports=(("stored", "storedItems[]"),),
+ input_params=("target", "writeMode", "preserveLineage"),
+ output_artifacts=("storedRefs", "collected_records"),
+ permission_gate=("canWriteInbox",),
+ config_gate=("data_sources", "collection_tasks", "collected_records"),
+ event_shape=("partial:storedRefs", "completed"),
+ fixture_coverage=("workflow-capabilities-api",),
+ ),
+ "workflow.inbox.store": RuntimeIOContract(
+ binding_id="workflow.inbox.store",
+ status="executable",
+ input_ports=(("in", "recordCandidate[]"),),
+ output_ports=(("stored", "storedItems[]"),),
+ input_params=("queue", "writeMode", "archive", "preserveLineage"),
+ output_artifacts=("storedRefs",),
+ permission_gate=("canWriteInbox",),
+ config_gate=("queue",),
+ event_shape=("partial:storedRecordCount", "completed"),
+ fixture_coverage=("happy-path", "sse-parity", "odp-redis-mirror"),
+ ),
+ "workflow.notify.send": RuntimeIOContract(
+ binding_id="workflow.notify.send",
+ status="projection_only",
+ input_ports=(("in", "recordCandidate[]"),),
+ output_ports=(("payload", "notificationPayload"),),
+ input_params=("notifier_type", "template", "target", "delivery_configured"),
+ output_artifacts=("notificationPayload",),
+ permission_gate=("canSendNotifications",),
+ config_gate=("delivery_projection", "configured_notifier_target"),
+ event_shape=("partial:inputItemCount", "blocked:missing_delivery_projection", "completed"),
+ fixture_coverage=("happy-path", "permission-blocked", "missing-webhook-url"),
+ ),
+ "workflow.notifier.webhook.send": RuntimeIOContract(
+ binding_id="workflow.notifier.webhook.send",
+ status="blocked_until_preconditions",
+ input_ports=(("in", "EvidenceBatch"),),
+ output_ports=(("delivery", "webhookDeliveryAttempt"),),
+ input_params=("template", "target", "adapter_mode"),
+ output_artifacts=("webhookDeliveryAttempt",),
+ permission_gate=("canSendNotifications",),
+ config_gate=("evidencebatch_projection_api", "delivery_projection", "webhook_url"),
+ event_shape=(
+ "partial:webhookDeliveryAttempt",
+ "completed",
+ "blocked:missing_delivery_projection",
+ ),
+ fixture_coverage=(
+ "webhook-real-delivery",
+ "webhook-missing-permission",
+ "webhook-missing-projection",
+ "missing-webhook-url",
+ "workflow-capabilities-api",
+ ),
+ real_webhook_delivery=True,
+ ),
+ "turbopush.local.publish": RuntimeIOContract(
+ binding_id="turbopush.local.publish",
+ status="blocked_until_preconditions",
+ input_ports=(("in", "recordCandidate[]"),),
+ output_ports=(("publish", "turbopushPublishResult"),),
+ input_params=("contentType", "contentSource", "targetPlatforms", "accountSelector"),
+ output_artifacts=("publishResult",),
+ permission_gate=("canSendNotifications",),
+ config_gate=("turbopush_local_service", "contentType"),
+ event_shape=("partial:publishResult", "blocked:missing_turbopush_service", "completed"),
+ fixture_coverage=("missing-runtime-resource", "workflow-turbopush-publish-api"),
+ ),
+ "workflow.external-tool.capability": RuntimeIOContract(
+ binding_id="workflow.external-tool.capability",
+ status="blocked_until_preconditions",
+ input_ports=(("in", "unknown"),),
+ output_ports=(("out", "unknown"),),
+ input_params=("toolCapabilityId", "executorMode", "toolParams"),
+ output_artifacts=("toolOutput",),
+ permission_gate=("canvas_review_required",),
+ config_gate=("tool_capability_registry", "node_params.toolCapability"),
+ event_shape=("tool_call_started", "partial:outputItemCount", "tool_call_completed"),
+ fixture_coverage=("workflow-capabilities-api", "workflow-tool-capabilities-api"),
+ ),
+}
+
+
+def list_runtime_io_contracts() -> list[RuntimeIOContract]:
+ return [RUNTIME_IO_CONTRACTS[key] for key in sorted(RUNTIME_IO_CONTRACTS)]
+
+
+def runtime_io_contract(binding_id: str | None) -> RuntimeIOContract | None:
+ if not binding_id:
+ return None
+ return RUNTIME_IO_CONTRACTS.get(binding_id)
+
+
+def runtime_io_contract_manifest(binding_id: str | None) -> dict[str, object] | None:
+ contract = runtime_io_contract(binding_id)
+ return contract.to_manifest() if contract else None
+
+
+def has_runtime_io_contract(binding_id: str | None) -> bool:
+ return runtime_io_contract(binding_id) is not None
+
+
+def _port(name: str, type_: str) -> dict[str, str]:
+ return {"name": name, "type": type_}
+
+
+__all__ = [
+ "RUNTIME_IO_CONTRACTS",
+ "RuntimeIOContract",
+ "RuntimeIOContractStatus",
+ "has_runtime_io_contract",
+ "list_runtime_io_contracts",
+ "runtime_io_contract",
+ "runtime_io_contract_manifest",
+]
diff --git a/backend/workflow/runtime_registry.py b/backend/workflow/runtime_registry.py
index a8f6546..b798806 100644
--- a/backend/workflow/runtime_registry.py
+++ b/backend/workflow/runtime_registry.py
@@ -7,6 +7,16 @@
from pydantic import BaseModel, Field
from backend.schemas.workflow import WorkflowAdapterBinding, WorkflowProjectNode
+from backend.workflow.block_reasons import (
+ MISSING_DELIVERY_PROJECTION,
+ MISSING_RUNTIME_BINDING,
+ MISSING_RUNTIME_IO_CONTRACT,
+ MISSING_RUNTIME_PARAMETER,
+ MISSING_TOOL_CAPABILITY_BINDING,
+ MISSING_TURBOPUSH_CONTENT_TYPE,
+ MISSING_TURBOPUSH_SERVICE,
+)
+from backend.workflow.runtime_contracts import runtime_io_contract_manifest
from backend.workflow.tool_capabilities import resolve_workflow_tool_capability
from backend.workflow.turbopush_runtime import (
TURBOPUSH_BINDING_ID,
@@ -24,13 +34,17 @@
OPENCLI_FUNCTION_ID = "odp.collect::opencli_snapshot"
DEMAND_DRAFT_BINDING_ID = "workflow.demand-draft.patch"
SCHEDULE_TRIGGER_BINDING_ID = "workflow.trigger.schedule_tick"
+SOURCE_FETCH_BINDING_ID = "workflow.source.fetch"
SOURCE_POOL_BINDING_ID = "workflow.source-pool.parallel-fanout"
COLLECTION_OUTPUT_BINDING_ID = "workflow.collection-output.items"
NORMALIZE_BINDING_ID = "workflow.transform.normalize"
MERGE_BINDING_ID = "workflow.flow.merge"
+ROUTER_ROUTE_BINDING_ID = "workflow.router.route"
RECORD_ACCEPTANCE_BINDING_ID = "workflow.gate.record-acceptance"
RECORD_SINK_BINDING_ID = "workflow.record-sink.records"
+INBOX_STORE_BINDING_ID = "workflow.inbox.store"
WEBHOOK_NOTIFY_BINDING_ID = "workflow.notifier.webhook.send"
+NOTIFY_SEND_BINDING_ID = "workflow.notify.send"
EXTERNAL_TOOL_BINDING_ID = "workflow.external-tool.capability"
SUPPORTED_TOOL_EXECUTOR_MODES = {"fixture", "okx_market_ticker_snapshot"}
@@ -67,46 +81,60 @@ def resolve_runtime_metadata(
resolved_node_id = node_id or node.id
if _is_collection_need(node):
- return _resolve_collection_need(node, node_id=resolved_node_id)
- if _is_schedule_trigger(node):
- return _resolve_schedule_trigger(node, node_id=resolved_node_id)
- if _is_source_pool(node):
- return _resolve_source_pool(node, node_id=resolved_node_id)
- if _is_collection_output(node):
- return _resolve_collection_output(node, node_id=resolved_node_id)
- if _is_normalize_node(node):
- return _resolve_normalize_node(node, node_id=resolved_node_id)
- if _is_merge_node(node):
- return _resolve_merge_node(node, node_id=resolved_node_id)
- if _is_record_acceptance_gate(node):
- return _resolve_record_acceptance_gate(node, node_id=resolved_node_id)
- if _is_record_sink(node):
- return _resolve_record_sink(node, node_id=resolved_node_id)
- if _is_external_tool_capability(node):
- return _resolve_external_tool_capability(node, node_id=resolved_node_id)
- if _is_turbopush_publish(node, adapter):
- return _resolve_turbopush_publish(node, adapter, node_id=resolved_node_id)
- if _is_webhook_notifier(node, adapter):
- return _resolve_webhook_notifier(node, adapter, node_id=resolved_node_id)
- if _is_opencli_source(node, adapter):
- return _resolve_opencli_source(node, adapter, node_id=resolved_node_id)
-
- return {
- "missing_runtime": _dump_missing_runtime(
- WorkflowMissingRuntime(
- code="missing_runtime_binding",
- node_id=resolved_node_id,
- kind=node.kind,
- capability=node.capability,
- adapter_id=adapter.id if adapter else None,
- provider=adapter.provider if adapter else None,
- message=(
- f"No runtime binding registered for "
- f"workflow.{node.kind}.{node.capability}"
- ),
+ metadata = _resolve_collection_need(node, node_id=resolved_node_id)
+ elif _is_schedule_trigger(node):
+ metadata = _resolve_schedule_trigger(node, node_id=resolved_node_id)
+ elif _is_source_pool(node):
+ metadata = _resolve_source_pool(node, node_id=resolved_node_id)
+ elif _is_collection_output(node):
+ metadata = _resolve_collection_output(node, node_id=resolved_node_id)
+ elif _is_normalize_node(node):
+ metadata = _resolve_normalize_node(node, node_id=resolved_node_id)
+ elif _is_merge_node(node):
+ metadata = _resolve_merge_node(node, node_id=resolved_node_id)
+ elif _is_router_route_node(node):
+ metadata = _resolve_router_route_node(node, node_id=resolved_node_id)
+ elif _is_record_acceptance_gate(node):
+ metadata = _resolve_record_acceptance_gate(node, node_id=resolved_node_id)
+ elif _is_record_sink(node):
+ metadata = _resolve_record_sink(node, node_id=resolved_node_id)
+ elif _is_inbox_store_node(node):
+ metadata = _resolve_inbox_store_node(node, node_id=resolved_node_id)
+ elif _is_external_tool_capability(node):
+ metadata = _resolve_external_tool_capability(node, node_id=resolved_node_id)
+ elif _is_turbopush_publish(node, adapter):
+ metadata = _resolve_turbopush_publish(node, adapter, node_id=resolved_node_id)
+ elif _is_webhook_notifier(node, adapter):
+ metadata = _resolve_webhook_notifier(node, adapter, node_id=resolved_node_id)
+ elif _is_opencli_source(node, adapter):
+ metadata = _resolve_opencli_source(node, adapter, node_id=resolved_node_id)
+ elif _is_source_fetch_node(node, adapter):
+ metadata = _resolve_source_fetch_node(node, adapter, node_id=resolved_node_id)
+ elif _is_notify_send_node(node, adapter):
+ metadata = _resolve_notify_send_node(node, adapter, node_id=resolved_node_id)
+ else:
+ metadata = {
+ "missing_runtime": _dump_missing_runtime(
+ WorkflowMissingRuntime(
+ code=MISSING_RUNTIME_BINDING,
+ node_id=resolved_node_id,
+ kind=node.kind,
+ capability=node.capability,
+ adapter_id=adapter.id if adapter else None,
+ provider=adapter.provider if adapter else None,
+ message=(
+ f"No runtime binding registered for workflow.{node.kind}."
+ f"{node.capability}"
+ ),
+ )
)
- )
- }
+ }
+ return _attach_runtime_contract(
+ metadata,
+ node=node,
+ adapter=adapter,
+ node_id=resolved_node_id,
+ )
def _resolve_opencli_source(
@@ -126,7 +154,7 @@ def _resolve_opencli_source(
return {
"missing_runtime": _dump_missing_runtime(
WorkflowMissingRuntime(
- code="missing_runtime_parameter",
+ code=MISSING_RUNTIME_PARAMETER,
node_id=node_id,
kind=node.kind,
capability=node.capability,
@@ -134,8 +162,7 @@ def _resolve_opencli_source(
provider=adapter.provider if adapter else None,
required_params=missing_params,
message=(
- "OpenCLI runtime binding requires node.params.site and "
- "node.params.command"
+ "OpenCLI runtime binding requires node.params.site and node.params.command"
),
)
)
@@ -193,6 +220,59 @@ def _resolve_source_pool(node: WorkflowProjectNode, *, node_id: str) -> dict[str
}
+def _resolve_source_fetch_node(
+ node: WorkflowProjectNode,
+ adapter: WorkflowAdapterBinding | None,
+ *,
+ node_id: str,
+) -> dict[str, Any]:
+ config = adapter.config if adapter else {}
+ provider = (
+ adapter.provider if adapter else _read_string(node.params.get("provider")) or "workflow"
+ )
+ channel_type = (
+ _read_string(node.params.get("channelType"))
+ or _read_string(node.params.get("channel_type"))
+ or _read_string(config.get("channelType"))
+ or _read_string(config.get("channel_type"))
+ or _read_string(config.get("channel"))
+ or provider
+ )
+ live_mode = (
+ _read_string(node.params.get("liveMode"))
+ or _read_string(config.get("liveMode"))
+ or (adapter.mode if adapter else None)
+ or "live"
+ )
+ source_id = _read_string(node.params.get("sourceId")) or _read_string(
+ node.params.get("dataSourceId")
+ )
+ return {
+ "binding": {
+ "status": "bound",
+ "binding_id": SOURCE_FETCH_BINDING_ID,
+ "runtime": "workflow",
+ "channel": "source",
+ "input": {
+ "provider": provider,
+ "channelType": channel_type,
+ "liveMode": live_mode,
+ "sourceId": source_id,
+ "adapterMode": adapter.mode if adapter else None,
+ "adapterConfig": config,
+ "params": dict(node.params),
+ "outputPort": "items[]",
+ },
+ },
+ "source_fetch": {
+ "node_id": node_id,
+ "provider": provider,
+ "channelType": channel_type,
+ "dispatch": "runtime_source_binding",
+ },
+ }
+
+
def _resolve_collection_output(node: WorkflowProjectNode, *, node_id: str) -> dict[str, Any]:
return {
"binding": {
@@ -257,9 +337,29 @@ def _resolve_merge_node(node: WorkflowProjectNode, *, node_id: str) -> dict[str,
}
-def _resolve_record_acceptance_gate(
- node: WorkflowProjectNode, *, node_id: str
-) -> dict[str, Any]:
+def _resolve_router_route_node(node: WorkflowProjectNode, *, node_id: str) -> dict[str, Any]:
+ expression = _read_string(node.params.get("expression")) or "true"
+ return {
+ "binding": {
+ "status": "bound",
+ "binding_id": ROUTER_ROUTE_BINDING_ID,
+ "runtime": "workflow",
+ "channel": "router",
+ "input": {
+ "expression": expression,
+ "mode": _read_string(node.params.get("mode")) or "filter",
+ "inputPort": "recordCandidate[]",
+ "outputPort": "recordCandidate[]",
+ },
+ },
+ "router": {
+ "node_id": node_id,
+ "expression": expression,
+ },
+ }
+
+
+def _resolve_record_acceptance_gate(node: WorkflowProjectNode, *, node_id: str) -> dict[str, Any]:
return {
"binding": {
"status": "bound",
@@ -302,9 +402,29 @@ def _resolve_record_sink(node: WorkflowProjectNode, *, node_id: str) -> dict[str
}
-def _resolve_external_tool_capability(
- node: WorkflowProjectNode, *, node_id: str
-) -> dict[str, Any]:
+def _resolve_inbox_store_node(node: WorkflowProjectNode, *, node_id: str) -> dict[str, Any]:
+ queue = _read_string(node.params.get("queue")) or "workflow-inbox"
+ return {
+ "binding": {
+ "status": "bound",
+ "binding_id": INBOX_STORE_BINDING_ID,
+ "runtime": "workflow",
+ "channel": "inbox",
+ "input": {
+ "queue": queue,
+ "writeMode": _read_string(node.params.get("writeMode")) or "append",
+ "archive": bool(node.params.get("archive", False)),
+ "preserveLineage": node.params.get("preserveLineage") is not False,
+ },
+ },
+ "inbox": {
+ "node_id": node_id,
+ "queue": queue,
+ },
+ }
+
+
+def _resolve_external_tool_capability(node: WorkflowProjectNode, *, node_id: str) -> dict[str, Any]:
tool_capability = _read_dict(node.params.get("toolCapability"))
capability_id = _read_string(tool_capability.get("id")) or _read_string(
node.params.get("toolCapabilityId")
@@ -321,14 +441,13 @@ def _resolve_external_tool_capability(
},
"missing_runtime": _dump_missing_runtime(
WorkflowMissingRuntime(
- code="missing_tool_capability_binding",
+ code=MISSING_TOOL_CAPABILITY_BINDING,
node_id=node_id,
kind=node.kind,
capability=node.capability,
required_params=[
"toolCapability.id",
- "toolCapability.executor.mode in "
- f"{sorted(SUPPORTED_TOOL_EXECUTOR_MODES)}",
+ f"toolCapability.executor.mode in {sorted(SUPPORTED_TOOL_EXECUTOR_MODES)}",
],
message=(
"Imported external tool node requires an OpenCLI Admin "
@@ -402,7 +521,7 @@ def _resolve_turbopush_publish(
return {
"missing_runtime": _dump_missing_runtime(
WorkflowMissingRuntime(
- code="missing_turbopush_content_type",
+ code=MISSING_TURBOPUSH_CONTENT_TYPE,
node_id=node_id,
kind=node.kind,
capability=node.capability,
@@ -432,7 +551,7 @@ def _resolve_turbopush_publish(
"turbopush": publish_contract,
"missing_runtime": _dump_missing_runtime(
WorkflowMissingRuntime(
- code="missing_turbopush_service",
+ code=MISSING_TURBOPUSH_SERVICE,
node_id=node_id,
kind=node.kind,
capability=node.capability,
@@ -491,14 +610,11 @@ def _resolve_webhook_notifier(
node_id: str,
) -> dict[str, Any]:
config = adapter.config if adapter else {}
+ webhook_url = _read_string(config.get("url")) or _read_string(config.get("webhook_url"))
target = (
- _read_string(node.params.get("target"))
- or _read_string(config.get("target"))
- or "webhook"
- )
- delivery_configured = bool(
- _read_string(config.get("url")) or _read_string(config.get("webhook_url"))
+ _read_string(node.params.get("target")) or _read_string(config.get("target")) or "webhook"
)
+ delivery_configured = bool(webhook_url)
notifier_contract = {
"node_id": node_id,
"type": "webhook",
@@ -506,18 +622,20 @@ def _resolve_webhook_notifier(
"dispatch": "blocked_until_projection",
"input": {
"notifier_type": "webhook",
- "template": _read_string(node.params.get("template")) or "brief",
- "target": target,
- "adapter_mode": adapter.mode if adapter else "webhook",
- "delivery_configured": delivery_configured,
- },
- }
+ "template": _read_string(node.params.get("template")) or "brief",
+ "target": target,
+ "adapter_mode": adapter.mode if adapter else "webhook",
+ "delivery_configured": delivery_configured,
+ "url": webhook_url,
+ "config": config,
+ },
+ }
if not delivery_configured:
return {
"notifier": notifier_contract,
"missing_runtime": _dump_missing_runtime(
WorkflowMissingRuntime(
- code="missing_delivery_projection",
+ code=MISSING_DELIVERY_PROJECTION,
node_id=node_id,
kind=node.kind,
capability=node.capability,
@@ -549,6 +667,8 @@ def _resolve_webhook_notifier(
"target": target,
"adapter_mode": adapter.mode if adapter else "webhook",
"delivery_configured": delivery_configured,
+ "url": webhook_url,
+ "config": config,
},
},
"notifier": {
@@ -559,15 +679,56 @@ def _resolve_webhook_notifier(
}
+def _resolve_notify_send_node(
+ node: WorkflowProjectNode,
+ adapter: WorkflowAdapterBinding | None,
+ *,
+ node_id: str,
+) -> dict[str, Any]:
+ config = adapter.config if adapter else {}
+ provider = adapter.provider if adapter else "workflow"
+ notifier_type = (
+ _read_string(config.get("notifierType"))
+ or _read_string(config.get("notifier_type"))
+ or ("webhook" if provider in {"generic-webhook", "webhook"} else provider)
+ )
+ target = (
+ _read_string(node.params.get("target"))
+ or _read_string(config.get("target"))
+ or notifier_type
+ )
+ delivery_configured = bool(
+ _read_string(config.get("url")) or _read_string(config.get("webhook_url"))
+ )
+ return {
+ "binding": {
+ "status": "bound",
+ "binding_id": NOTIFY_SEND_BINDING_ID,
+ "runtime": "workflow",
+ "channel": "notifier",
+ "input": {
+ "notifier_type": notifier_type,
+ "template": _read_string(node.params.get("template")) or "brief",
+ "target": target,
+ "adapter_mode": adapter.mode if adapter else "mock",
+ "delivery_configured": delivery_configured,
+ "config": config,
+ },
+ },
+ "notifier": {
+ "node_id": node_id,
+ "type": notifier_type,
+ "dispatch": "guarded_delivery" if delivery_configured else "blocked_until_delivery",
+ },
+ }
+
+
def _is_collection_need(node: WorkflowProjectNode) -> bool:
ui = node.ui or {}
- return (
- _read_string(ui.get("catalogId")) == "intelligence.input.collection-need"
- or (
- node.kind == "schedule"
- and node.capability == "trigger"
- and _read_string(node.params.get("mode")) == "demand-draft"
- )
+ return _read_string(ui.get("catalogId")) == "intelligence.input.collection-need" or (
+ node.kind == "schedule"
+ and node.capability == "trigger"
+ and _read_string(node.params.get("mode")) == "demand-draft"
)
@@ -575,6 +736,13 @@ def _is_source_pool(node: WorkflowProjectNode) -> bool:
return _read_string((node.ui or {}).get("catalogId")) == "intelligence.source.pool"
+def _is_source_fetch_node(
+ node: WorkflowProjectNode,
+ adapter: WorkflowAdapterBinding | None,
+) -> bool:
+ return node.kind == "source" and node.capability == "fetch" and adapter is not None
+
+
def _is_collection_output(node: WorkflowProjectNode) -> bool:
return _read_string((node.ui or {}).get("catalogId")) == "intelligence.output.collection-result"
@@ -582,31 +750,42 @@ def _is_collection_output(node: WorkflowProjectNode) -> bool:
def _is_normalize_node(node: WorkflowProjectNode) -> bool:
if node.internals or node.topicCollapse or node.miniNetwork:
return False
- return (
- _read_string((node.ui or {}).get("catalogId")) == "intelligence.processing.normalize"
- or (node.kind == "agent" and node.capability == "normalize")
+ return _read_string(
+ (node.ui or {}).get("catalogId")
+ ) == "intelligence.processing.normalize" or (
+ node.kind == "agent" and node.capability == "normalize"
)
def _is_merge_node(node: WorkflowProjectNode) -> bool:
- return (
- _read_string((node.ui or {}).get("catalogId")) == "intelligence.flow.merge"
- or (node.kind == "flow" and node.capability == "merge")
+ return _read_string((node.ui or {}).get("catalogId")) == "intelligence.flow.merge" or (
+ node.kind == "flow" and node.capability == "merge"
+ )
+
+
+def _is_router_route_node(node: WorkflowProjectNode) -> bool:
+ return _read_string((node.ui or {}).get("catalogId")) == "intelligence.router.importance" or (
+ node.kind == "router" and node.capability == "route"
)
def _is_record_acceptance_gate(node: WorkflowProjectNode) -> bool:
- return (
- _read_string((node.ui or {}).get("catalogId"))
- == "intelligence.control.record-acceptance"
- or (node.kind == "control" and node.capability == "accept")
+ return _read_string(
+ (node.ui or {}).get("catalogId")
+ ) == "intelligence.control.record-acceptance" or (
+ node.kind == "control" and node.capability == "accept"
)
def _is_record_sink(node: WorkflowProjectNode) -> bool:
- return (
- _read_string((node.ui or {}).get("catalogId")) == "intelligence.sink.records"
- or (node.kind == "sink" and node.capability == "store")
+ return _read_string((node.ui or {}).get("catalogId")) == "intelligence.sink.records" or (
+ node.kind == "sink" and node.capability == "store"
+ )
+
+
+def _is_inbox_store_node(node: WorkflowProjectNode) -> bool:
+ return _read_string((node.ui or {}).get("catalogId")) == "intelligence.output.inbox" or (
+ node.kind == "inbox" and node.capability == "store"
)
@@ -644,6 +823,13 @@ def _is_webhook_notifier(
return adapter.provider == "webhook" or notifier_type == "webhook"
+def _is_notify_send_node(
+ node: WorkflowProjectNode,
+ adapter: WorkflowAdapterBinding | None,
+) -> bool:
+ return node.kind == "notify" and node.capability == "send" and adapter is not None
+
+
def _is_turbopush_publish(
node: WorkflowProjectNode,
adapter: WorkflowAdapterBinding | None,
@@ -686,3 +872,46 @@ def _dump_missing_runtime(missing_runtime: WorkflowMissingRuntime) -> dict[str,
if not payload.get("required_params"):
payload.pop("required_params", None)
return payload
+
+
+def _attach_runtime_contract(
+ metadata: dict[str, Any],
+ *,
+ node: WorkflowProjectNode,
+ adapter: WorkflowAdapterBinding | None,
+ node_id: str,
+) -> dict[str, Any]:
+ result = dict(metadata)
+ binding = _read_dict(result.get("binding"))
+ if binding:
+ binding_id = _read_string(binding.get("binding_id"))
+ contract = runtime_io_contract_manifest(binding_id)
+ if contract is None:
+ result.pop("binding", None)
+ result["missing_runtime"] = _dump_missing_runtime(
+ WorkflowMissingRuntime(
+ code=MISSING_RUNTIME_IO_CONTRACT,
+ node_id=node_id,
+ kind=node.kind,
+ capability=node.capability,
+ adapter_id=adapter.id if adapter else None,
+ provider=adapter.provider if adapter else None,
+ required_params=["runtime_io_contract"],
+ message=(
+ f'Runtime binding "{binding_id or "unknown"}" exists but '
+ "does not declare a real node I/O contract."
+ ),
+ )
+ )
+ else:
+ result["binding"] = {**binding, "contract": contract}
+
+ for key, value in list(result.items()):
+ if key in {"binding", "missing_runtime"} or not isinstance(value, dict):
+ continue
+ binding_id = _read_string(value.get("binding_id"))
+ contract = runtime_io_contract_manifest(binding_id)
+ if contract is not None:
+ result[key] = {**value, "contract": contract}
+
+ return result
diff --git a/backend/workflow/webhook_delivery.py b/backend/workflow/webhook_delivery.py
new file mode 100644
index 0000000..b450385
--- /dev/null
+++ b/backend/workflow/webhook_delivery.py
@@ -0,0 +1,113 @@
+"""Workflow webhook delivery executor."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from backend.notifiers.base import NotificationPayload
+from backend.notifiers.registry import get_notifier
+
+WEBHOOK_DELIVERY_EVENT = "workflow.evidence_batch.ready"
+WEBHOOK_DELIVERY_PAYLOAD_SCHEMA = "workflow.webhook.evidence_batch.v1"
+
+
+class WorkflowWebhookDeliveryError(Exception):
+ def __init__(self, code: str, message: str, details: dict[str, Any]) -> None:
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = details
+
+
+async def execute_workflow_webhook_delivery(
+ binding_input: dict[str, Any],
+ input_items: list[dict[str, Any]],
+ *,
+ workflow_id: str,
+ run_id: str,
+ node_id: str,
+) -> dict[str, Any]:
+ config = _webhook_config(binding_input)
+ target = _read_string(binding_input.get("target")) or "webhook"
+ payload = NotificationPayload(
+ event=WEBHOOK_DELIVERY_EVENT,
+ source_id=workflow_id,
+ record_id=run_id,
+ data={
+ "schema": WEBHOOK_DELIVERY_PAYLOAD_SCHEMA,
+ "workflowId": workflow_id,
+ "workflowRunId": run_id,
+ "nodeId": node_id,
+ "target": target,
+ "itemCount": len(input_items),
+ "items": [_safe_delivery_item(item) for item in input_items],
+ },
+ )
+
+ delivered = await get_notifier("webhook").send(config, payload)
+ if not delivered:
+ raise WorkflowWebhookDeliveryError(
+ code="webhook_delivery_failed",
+ message="Webhook delivery attempted but the notifier returned a failure.",
+ details={
+ "nodeId": node_id,
+ "target": target,
+ "itemCount": len(input_items),
+ "payloadSchema": WEBHOOK_DELIVERY_PAYLOAD_SCHEMA,
+ },
+ )
+
+ return {
+ "notifierType": "webhook",
+ "target": target,
+ "deliveryAttempted": True,
+ "delivered": True,
+ "event": WEBHOOK_DELIVERY_EVENT,
+ "payloadSchema": WEBHOOK_DELIVERY_PAYLOAD_SCHEMA,
+ "itemCount": len(input_items),
+ }
+
+
+def _webhook_config(binding_input: dict[str, Any]) -> dict[str, Any]:
+ config = _read_dict(binding_input.get("config"))
+ url = _read_string(binding_input.get("url")) or _read_string(
+ config.get("url")
+ ) or _read_string(config.get("webhook_url"))
+ if url:
+ config = {**config, "url": url}
+ return config
+
+
+def _safe_delivery_item(item: dict[str, Any]) -> dict[str, Any]:
+ raw = _read_dict(item.get("raw"))
+ normalized = _read_dict(item.get("normalizedData"))
+ return {
+ "id": _read_string(raw.get("id"))
+ or _read_string(normalized.get("id"))
+ or _read_string(item.get("recordId")),
+ "title": _read_string(raw.get("title")) or _read_string(normalized.get("title")),
+ "url": _read_string(raw.get("url")) or _read_string(normalized.get("url")),
+ "lineage": _read_dict_list(item.get("lineage")),
+ }
+
+
+def _read_string(value: Any) -> str | None:
+ return value.strip() if isinstance(value, str) and value.strip() else None
+
+
+def _read_dict(value: Any) -> dict[str, Any]:
+ return value if isinstance(value, dict) else {}
+
+
+def _read_dict_list(value: Any) -> list[dict[str, Any]]:
+ if not isinstance(value, list):
+ return []
+ return [item for item in value if isinstance(item, dict)]
+
+
+__all__ = [
+ "WEBHOOK_DELIVERY_EVENT",
+ "WEBHOOK_DELIVERY_PAYLOAD_SCHEMA",
+ "WorkflowWebhookDeliveryError",
+ "execute_workflow_webhook_delivery",
+]
diff --git a/docker-compose.yml b/docker-compose.yml
index 74d8bc0..87387ef 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
# Shared backend config (DRY)
x-backend-common: &backend-common
- image: ${DOCKER_REGISTRY:-docker.io/}xjh1994/opencli-admin-api:${IMAGE_TAG:-0.3.6}
+ image: ${DOCKER_REGISTRY:-docker.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-api:${IMAGE_TAG:-0.3.6}
env_file:
- path: .env
required: false
@@ -55,6 +55,7 @@ services:
REDIS_URL: redis://redis:6379/0
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
+ API_AUTH_TOKEN: ${API_AUTH_TOKEN:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
# Built-in sidecar agent URL (Chrome + agent_server in one container).
@@ -101,6 +102,7 @@ services:
REDIS_URL: redis://redis:6379/0
RUN_MIGRATIONS: "false"
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
+ API_AUTH_TOKEN: ${API_AUTH_TOKEN:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://agent-1:19823}
@@ -123,6 +125,7 @@ services:
REDIS_URL: redis://redis:6379/0
RUN_MIGRATIONS: "false"
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
+ API_AUTH_TOKEN: ${API_AUTH_TOKEN:-}
depends_on:
redis:
condition: service_healthy
@@ -138,7 +141,7 @@ services:
# • macOS/Linux: open -a "Google Chrome" --args --remote-debugging-port=9222
# • Or start the Bridge daemon: node $(npm root -g)/@jackwener/opencli/dist/daemon.js
agent-1:
- image: ${DOCKER_REGISTRY:-docker.io/}xjh1994/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-}
+ image: ${DOCKER_REGISTRY:-docker.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-}
environment:
CENTRAL_API_URL: http://api:8000
# Use container name so the API can reach this agent by DNS within the Docker network
@@ -155,6 +158,8 @@ services:
# cdp mode: Chrome DevTools Protocol on host
OPENCLI_CDP_ENDPOINT: ${OPENCLI_CDP_ENDPOINT:-http://host.docker.internal:9222}
OPENCLI_TIMEOUT: ${OPENCLI_TIMEOUT:-120}
+ API_AUTH_TOKEN: ${API_AUTH_TOKEN:-}
+ AGENT_API_TOKEN: ${AGENT_API_TOKEN:-}
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
extra_hosts:
@@ -180,13 +185,13 @@ services:
# -e CENTRAL_API_URL=http://:8031 \
# -e AGENT_REGISTER=ws \
# -p 19823:19823 \
- # xjh1994/opencli-admin-agent:0.1.0
+ # 2233admin/opencli-admin-agent:0.3.6
#
# Or start the bundled agent profile (local network):
# docker compose --profile agent up agent
agent:
profiles: ["agent"]
- image: ${DOCKER_REGISTRY:-docker.io/}xjh1994/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-}
+ image: ${DOCKER_REGISTRY:-docker.io/}${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG:-0.3.6}${CHROME_SUFFIX:-}
environment:
CENTRAL_API_URL: ${CENTRAL_API_URL:-}
AGENT_ADVERTISE_URL: ${AGENT_ADVERTISE_URL:-}
@@ -195,6 +200,8 @@ services:
AGENT_LABEL: ${AGENT_LABEL:-}
AGENT_REGISTER: ${AGENT_REGISTER:-http}
OPENCLI_TIMEOUT: ${OPENCLI_TIMEOUT:-120}
+ API_AUTH_TOKEN: ${API_AUTH_TOKEN:-}
+ AGENT_API_TOKEN: ${AGENT_API_TOKEN:-}
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
volumes:
diff --git a/docs/workflow-runtime-conformance.md b/docs/workflow-runtime-conformance.md
new file mode 100644
index 0000000..6b6ac53
--- /dev/null
+++ b/docs/workflow-runtime-conformance.md
@@ -0,0 +1,113 @@
+# Workflow Runtime Conformance
+
+This backend conformance slice turns live workflow support into executable
+evidence. Runtime compatibility is treated as the triple of registry
+declaration, executable fixture, and observed transcript from
+`/api/v1/workflows/runs/{runId}/events`.
+
+## Current Slice
+
+Ordered conformance ladder:
+
+`block reason taxonomy -> config-blocked fixtures -> SSE parity -> ODP/Redis event mirror -> real node I/O contracts -> webhook real delivery`
+
+- Event source: workflow-run event snapshot API, plus SSE parity smoke for
+ `/api/v1/workflows/runs/{runId}/events/stream`.
+- Fixture builder: `tests/fixtures/workflow_conformance.py`.
+- Matcher and passport contracts: `backend/workflow/conformance/contracts.py`.
+- ODP/Redis event mirror: `backend/workflow/event_mirror.py`, stream
+ `odp.workflow_run.events` when `WORKFLOW_EVENT_MIRROR_BACKEND=redis`.
+- Real node I/O contracts: `backend/workflow/runtime_contracts.py`; compile
+ output and Canvas capability/status surfaces both project the same contract
+ summary for supported runtime bindings.
+- Webhook real delivery: `backend/workflow/webhook_delivery.py` sends through
+ the registered webhook notifier when send permission, a configured URL, and
+ upstream EvidenceBatch projection are all present.
+- Expected transcripts: `backend/workflow/conformance/expected_events/`.
+- Runtime passport artifact: caller-provided directory ending in
+ `opencli-runtime-passport.json`; generated files are ignored under
+ `.tmp/opencli-conformance/`.
+
+Covered cases:
+
+- `happy-path`: source outputs flow through normalize, router, inbox, and
+ generic notify delivery projection.
+- `permission-blocked`: missing fetch/send permissions produce stable blocked
+ events, not compile failure.
+- `missing-binding`: a schema-valid unsupported node reaches the runtime
+ registry and emits `missing_runtime_binding`.
+- `config-blocked`: missing webhook URL, source credential, and runtime
+ resource preconditions remain `valid=true`, project `blocked`, and match
+ stable block reason taxonomy entries. These fixtures prove blocked evidence
+ only; they do not certify real webhook delivery.
+- `sse-parity`: the canonical happy-path expected transcript is matched
+ against both `/events` snapshot output and `/events/stream` `node_event`
+ payloads.
+- `odp-redis-mirror`: the canonical happy-path expected transcript is matched
+ against workflow-run event records published through the Redis stream mirror
+ interface. The fixture uses a fake Redis client at the boundary, and the
+ production path uses Redis `XADD` when `WORKFLOW_EVENT_MIRROR_BACKEND=redis`.
+- `real-node-io-contracts`: every declared runtime binding now has input
+ shape, output shape, permission gate, config gate, event shape, and fixture
+ coverage. Bindings missing that contract are converted to blocked runtime
+ metadata instead of being exposed as runnable, and Canvas manifests only
+ expose stable summaries.
+- `webhook-real-delivery`: the webhook notifier performs a real POST through
+ the registered notifier path under deterministic request capture. Missing
+ send permission, missing URL, and missing EvidenceBatch/projection all remain
+ stable blocked cases and do not send HTTP.
+
+## Block Reason Taxonomy
+
+Stable block reason codes live in `backend/workflow/block_reasons.py`.
+
+Stable matcher inputs:
+
+- `code`
+- `source`
+- selected `details.*` keys listed in the taxonomy definition
+- conformance `blockReasonCategory`
+
+Volatile diagnostics:
+
+- generated event ids
+- timestamps
+- run/trace ids
+- SSE transport framing and `run_state` events
+- Redis stream entry ids
+- environment-specific resource paths such as local MCP config paths
+- free-text messages unless a fixture asserts a short substring
+
+## Verification
+
+Last verified on 2026-07-06:
+
+```powershell
+.\.venv\Scripts\python.exe -m ruff check backend\workflow\block_reasons.py backend\workflow\event_mirror.py backend\workflow\runtime_registry.py backend\workflow\opencli_hda_tracer.py backend\workflow\conformance\contracts.py backend\workflow\conformance\__init__.py tests\fixtures\workflow_conformance.py tests\integration\test_workflow_conformance.py
+.\.venv\Scripts\python.exe -m ruff check backend\workflow\runtime_contracts.py backend\workflow\webhook_delivery.py backend\workflow\capability_projection.py tests\integration\test_workflow_capabilities_api.py
+.\.venv\Scripts\python.exe -m pytest tests\integration\test_workflow_conformance.py -q --no-cov
+.\.venv\Scripts\python.exe -m pytest tests\integration\test_workflow_capabilities_api.py -q --no-cov
+.\.venv\Scripts\python.exe -m pytest tests\integration\test_workflow_opencli_hda_trace_api.py -q --no-cov
+.\.venv\Scripts\python.exe -m pytest tests\integration\test_workflow_turbopush_publish_api.py -q --no-cov
+openspec validate runtime-conformance-next-granularity --strict
+openspec validate workflow-runtime-conformance --strict
+pwsh -NoLogo -NoProfile -File C:\c\Users\Administrator\projects\code-intel-pipeline\check-code-intel-tools.ps1 -RepoPath C:\c\Users\Administrator\projects\opencli-admin-backend -Json
+pwsh -NoLogo -NoProfile -File C:\c\Users\Administrator\projects\code-intel-pipeline\Invoke-SentruxAgentTool.ps1 check_rules C:\c\Users\Administrator\projects\opencli-admin-backend
+pwsh -NoLogo -NoProfile -File C:\c\Users\Administrator\projects\code-intel-pipeline\Invoke-SentruxAgentTool.ps1 test_gaps C:\c\Users\Administrator\projects\opencli-admin-backend
+pwsh -NoLogo -NoProfile -File C:\c\Users\Administrator\projects\code-intel-pipeline\invoke-code-intel.ps1 -RepoPath C:\c\Users\Administrator\projects\opencli-admin-backend -Mode normal
+```
+
+Focused backend tests, ruff check, and OpenSpec passed:
+`test_workflow_conformance.py` reported 15 passed,
+`test_workflow_capabilities_api.py` reported 5 passed,
+`test_workflow_opencli_hda_trace_api.py` reported 15 passed, and
+`test_workflow_turbopush_publish_api.py` reported 6 passed. Code Intel normal
+mode produced artifacts under
+`C:\Users\Administrator\AppData\Local\code-intel\artifacts\opencli-admin-backend\20260706-220352`;
+it reported 6 passed steps, 1 skipped step (`node lint hygiene`), and failed
+only at `sentrux gate`.
+
+The Sentrux failure category was `sentrux_fail=1`; `provider_quota`,
+`local_tool_error`, and `graph_missing` were all zero. `check_rules` passed.
+The reported Sentrux hotspot was `frontend/lib/flow/store.ts`
+(`useFlowStore`, cc=124), outside this backend conformance slice.
diff --git a/frontend/components/flow/node-context-menu.tsx b/frontend/components/flow/node-context-menu.tsx
new file mode 100644
index 0000000..131ff08
--- /dev/null
+++ b/frontend/components/flow/node-context-menu.tsx
@@ -0,0 +1,198 @@
+import { primitiveRuntimeCapability, runtimeStatusLabel, runtimeStatusTone, type WorkflowCapabilitiesResponse } from "@/lib/workflow/capabilities"
+import { localizeNodeText, type WorkflowLanguage } from "@/lib/workflow/node-i18n"
+import type { WorkflowNodeCatalogItem } from "@/lib/workflow/node-catalog"
+import type { WorkflowPrimitive } from "@/lib/workflow/node-primitives"
+import { getNodeVisualSignature } from "@/lib/workflow/node-visuals"
+import { cn } from "@/lib/utils"
+
+type NodeMenuState = { nodeId: string; x: number; y: number }
+type PrimitiveMenuGroup = {
+ category: string
+ label: string
+ items: WorkflowPrimitive[]
+}
+
+type NodeContextMenuProps = {
+ capabilities: WorkflowCapabilitiesResponse | null | undefined
+ dopNodeMenuItems: WorkflowNodeCatalogItem[]
+ language: WorkflowLanguage
+ menu: NodeMenuState
+ onAddDopNode: (item: WorkflowNodeCatalogItem) => void
+ onAddPrimitive: (item: WorkflowPrimitive, itemIndex: number) => void
+ onDiveIntoNetwork: (nodeId: string) => void
+ onLockInternals: (nodeId: string) => void
+ onSelectComponent: (nodeId: string) => void
+ onShowNodeInfo: () => void
+ onShowParameters: () => void
+ onUnlockInternals: (nodeId: string) => void
+ primitiveMenuGroups: PrimitiveMenuGroup[]
+ wrapperElement: HTMLElement | null
+}
+
+export function NodeContextMenu({
+ capabilities,
+ dopNodeMenuItems,
+ language,
+ menu,
+ onAddDopNode,
+ onAddPrimitive,
+ onDiveIntoNetwork,
+ onLockInternals,
+ onSelectComponent,
+ onShowNodeInfo,
+ onShowParameters,
+ onUnlockInternals,
+ primitiveMenuGroups,
+ wrapperElement,
+}: NodeContextMenuProps) {
+ return (
+ event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ >
+
+
+
+ DOP Operators
+
+
+ {dopNodeMenuItems.map((item) => {
+ const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language)
+ const visual = getNodeVisualSignature({
+ label: item.label,
+ description: item.description,
+ nodeType: item.kind === "router" ? "condition" : item.kind === "schedule" ? "trigger" : item.kind === "source" ? "http" : "action",
+ category: item.category === "decision" ? "logic" : item.category === "trigger" ? "trigger" : item.category === "source" ? "data" : item.category === "output" ? "action" : "action",
+ icon: item.icon,
+ canonical: { catalogId: item.id, kind: item.kind, capability: item.capability },
+ })
+ return (
+
+ )
+ })}
+
+
+
+ Add Internal Primitive
+ ›
+
+
+ {primitiveMenuGroups.map((group) => (
+
+
+ {group.label}
+ ›
+
+
+ {group.items.map((item, itemIndex) => {
+ const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language)
+ const runtimeCapability = primitiveRuntimeCapability(capabilities, item.id)
+ const visual = getNodeVisualSignature({
+ label: item.label,
+ description: item.description,
+ nodeType: item.nodeType,
+ category: item.nodeCategory,
+ icon: item.icon,
+ primitiveId: item.id,
+ primitiveCategory: item.category,
+ })
+ return (
+
+ )
+ })}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
Help...
+
+ )
+}
diff --git a/frontend/components/flow/workflow-agent-proposal.ts b/frontend/components/flow/workflow-agent-proposal.ts
new file mode 100644
index 0000000..53e1825
--- /dev/null
+++ b/frontend/components/flow/workflow-agent-proposal.ts
@@ -0,0 +1,78 @@
+import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"
+
+import { useFlowStore } from "@/lib/flow/store"
+import { acceptAgentProposal, type AgentProposal } from "@/lib/workflow/proposal"
+import type { ProposalFocusTarget } from "@/lib/workflow/proposal-focus"
+
+type FitView = (options?: { padding?: number; duration?: number; nodes?: { id: string }[] }) => unknown
+
+export function useWorkflowAgentProposal(options: {
+ clearPendingAgentProposal: () => void
+ clearProposalFocus: () => void
+ fitView: FitView
+ focusProposalTargets: (nodeIds: string[], edgeIds: string[]) => void
+ importWorkflowProject: (project: ReturnType) => void
+ pendingAgentProposal: AgentProposal | null | undefined
+ setAgentDrawerOpen: Dispatch>
+ showToast: (message: string) => void
+}) {
+ const {
+ clearPendingAgentProposal,
+ clearProposalFocus,
+ fitView,
+ focusProposalTargets,
+ importWorkflowProject,
+ pendingAgentProposal,
+ setAgentDrawerOpen,
+ showToast,
+ } = options
+ const [agentProposal, setAgentProposal] = useState(undefined)
+
+ const acceptProposal = useCallback(
+ (proposal: AgentProposal) => {
+ try {
+ importWorkflowProject(acceptAgentProposal(useFlowStore.getState().workflowProject, proposal))
+ showToast("Agent proposal accepted")
+ setAgentDrawerOpen(false)
+ setAgentProposal(undefined)
+ } catch (error) {
+ showToast(error instanceof Error ? error.message : "Agent proposal failed")
+ }
+ },
+ [importWorkflowProject, setAgentDrawerOpen, showToast],
+ )
+
+ const rejectProposal = useCallback(() => {
+ showToast("Agent proposal rejected")
+ clearProposalFocus()
+ setAgentDrawerOpen(false)
+ setAgentProposal(undefined)
+ }, [clearProposalFocus, setAgentDrawerOpen, showToast])
+
+ const presentAgentProposal = useCallback(
+ (proposal: AgentProposal) => {
+ setAgentProposal(proposal)
+ setAgentDrawerOpen(true)
+ showToast("Demand proposal ready")
+ },
+ [setAgentDrawerOpen, showToast],
+ )
+
+ useEffect(() => {
+ if (!pendingAgentProposal) return
+ presentAgentProposal(pendingAgentProposal)
+ clearPendingAgentProposal()
+ }, [clearPendingAgentProposal, pendingAgentProposal, presentAgentProposal])
+
+ const focusProposalOperation = useCallback(
+ (focus: ProposalFocusTarget) => {
+ focusProposalTargets(focus.nodeIds, focus.edgeIds)
+ if (focus.nodeIds.length > 0) {
+ window.setTimeout(() => void fitView({ nodes: focus.nodeIds.map((id) => ({ id })), padding: 0.35, duration: 280 }), 20)
+ }
+ },
+ [fitView, focusProposalTargets],
+ )
+
+ return { acceptProposal, agentProposal, focusProposalOperation, rejectProposal }
+}
diff --git a/frontend/components/flow/workflow-canvas-geometry.ts b/frontend/components/flow/workflow-canvas-geometry.ts
new file mode 100644
index 0000000..8b38b4e
--- /dev/null
+++ b/frontend/components/flow/workflow-canvas-geometry.ts
@@ -0,0 +1,39 @@
+import type { MouseEvent as ReactMouseEvent } from "react"
+
+export type CanvasPoint = { x: number; y: number }
+
+function distance(a: CanvasPoint, b: CanvasPoint) {
+ return Math.hypot(a.x - b.x, a.y - b.y)
+}
+
+export function edgeIdsAtScreenPoint(point: CanvasPoint, threshold = 10): string[] {
+ const hits: string[] = []
+ const edges = document.querySelectorAll(".react-flow__edge[data-id]")
+
+ edges.forEach((edge) => {
+ const id = edge.dataset.id
+ const path = edge.querySelector("path.react-flow__edge-path, path[id]")
+ if (!id || !path) return
+ const ctm = path.getScreenCTM()
+ if (!ctm) return
+
+ const total = path.getTotalLength()
+ const steps = Math.max(16, Math.ceil(total / 18))
+ for (let i = 0; i <= steps; i++) {
+ const svgPoint = path.getPointAtLength((total * i) / steps)
+ const screenPoint = new DOMPoint(svgPoint.x, svgPoint.y).matrixTransform(ctm)
+ if (distance(point, screenPoint) <= threshold) {
+ hits.push(id)
+ return
+ }
+ }
+ })
+
+ return hits
+}
+
+export function localPoint(element: HTMLElement | null, event: ReactMouseEvent): CanvasPoint {
+ const rect = element?.getBoundingClientRect()
+ if (!rect) return { x: event.clientX, y: event.clientY }
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top }
+}
diff --git a/frontend/components/flow/workflow-canvas-interactions.ts b/frontend/components/flow/workflow-canvas-interactions.ts
new file mode 100644
index 0000000..d6378c5
--- /dev/null
+++ b/frontend/components/flow/workflow-canvas-interactions.ts
@@ -0,0 +1,254 @@
+import {
+ useCallback,
+ type Dispatch,
+ type DragEvent,
+ type MouseEvent as ReactMouseEvent,
+ type RefObject,
+ type SetStateAction,
+} from "react"
+import type { IsValidConnection, Node, OnBeforeDelete, OnNodeDrag } from "@xyflow/react"
+
+import { useFlowStore } from "@/lib/flow/store"
+import type { CanvasSettings } from "@/lib/flow/settings-store"
+import { validateConnection } from "@/lib/flow/graph"
+import type { PaletteItem, WorkflowEdge, WorkflowNode } from "@/lib/flow/types"
+import { edgeIdsAtScreenPoint, localPoint, type CanvasPoint } from "./workflow-canvas-geometry"
+
+type WritableRef = { current: T }
+type ScreenToFlowPosition = (position: CanvasPoint) => CanvasPoint
+type ShowToast = (message: string) => void
+
+export type ShakeState = {
+ lastX: number
+ lastDirection: -1 | 0 | 1
+ turns: number
+ disconnected: boolean
+}
+
+export function usePaletteDrop(options: {
+ addNodeFromPalette: (item: PaletteItem, position: CanvasPoint) => void
+ screenToFlowPosition: ScreenToFlowPosition
+}) {
+ const { addNodeFromPalette, screenToFlowPosition } = options
+ const onDragOver = useCallback((event: DragEvent) => {
+ event.preventDefault()
+ event.dataTransfer.dropEffect = "move"
+ }, [])
+
+ const onDrop = useCallback(
+ (event: DragEvent) => {
+ event.preventDefault()
+ const raw = event.dataTransfer.getData("application/reactflow")
+ if (!raw) return
+ addNodeFromPalette(JSON.parse(raw) as PaletteItem, screenToFlowPosition({ x: event.clientX, y: event.clientY }))
+ },
+ [screenToFlowPosition, addNodeFromPalette],
+ )
+
+ return { onDragOver, onDrop }
+}
+
+export function useScissorCanvasHandlers(options: {
+ cutRef: WritableRef>
+ draggingRef: WritableRef
+ removeEdgesByIds: (ids: string[]) => number
+ setTrail: Dispatch>
+ showToast: ShowToast
+ toolMode: string
+ wrapperRef: RefObject
+}) {
+ const { cutRef, draggingRef, removeEdgesByIds, setTrail, showToast, toolMode, wrapperRef } = options
+ const cutEdgesAtPoint = useCallback(
+ (event: ReactMouseEvent) => {
+ const hits = edgeIdsAtScreenPoint({ x: event.clientX, y: event.clientY })
+ const fresh = hits.filter((id) => !cutRef.current.has(id))
+ if (fresh.length === 0) return
+ fresh.forEach((id) => cutRef.current.add(id))
+ const removed = removeEdgesByIds(fresh)
+ if (removed > 0) showToast(`已剪断 ${removed} 条连接`)
+ },
+ [cutRef, removeEdgesByIds, showToast],
+ )
+
+ const onCanvasMouseDownCapture = useCallback(
+ (event: ReactMouseEvent) => {
+ if (toolMode !== "scissors" || event.button !== 0) return
+ event.preventDefault()
+ event.stopPropagation()
+ draggingRef.current = true
+ cutRef.current = new Set()
+ setTrail([localPoint(wrapperRef.current, event)])
+ cutEdgesAtPoint(event)
+ },
+ [cutEdgesAtPoint, cutRef, draggingRef, setTrail, toolMode, wrapperRef],
+ )
+
+ const onCanvasMouseMoveCapture = useCallback(
+ (event: ReactMouseEvent) => {
+ if (!draggingRef.current) return
+ event.preventDefault()
+ event.stopPropagation()
+ setTrail((trail) => {
+ const next = [...trail, localPoint(wrapperRef.current, event)]
+ return next.length > 80 ? next.slice(-80) : next
+ })
+ cutEdgesAtPoint(event)
+ },
+ [cutEdgesAtPoint, draggingRef, setTrail, wrapperRef],
+ )
+
+ const onCanvasMouseUpCapture = useCallback(
+ (event: ReactMouseEvent) => {
+ if (!draggingRef.current) return
+ event.preventDefault()
+ event.stopPropagation()
+ draggingRef.current = false
+ cutRef.current = new Set()
+ window.setTimeout(() => setTrail([]), 120)
+ },
+ [cutRef, draggingRef, setTrail],
+ )
+
+ return { onCanvasMouseDownCapture, onCanvasMouseMoveCapture, onCanvasMouseUpCapture }
+}
+
+export function useWorkflowNodeDragHandlers(options: {
+ attachToParent: (nodeId: string, parentId: string) => void
+ clearHelperLines: () => void
+ detachFromParent: (nodeId: string) => void
+ disconnectNodeConnections: (nodeId: string) => number
+ getInternalNode: (nodeId: string) => { internals?: { positionAbsolute?: CanvasPoint } } | undefined
+ resizeGroupToFit: (nodeId: string) => void
+ resolveNodeCollisions: (nodeId: string) => void
+ shakeRef: WritableRef