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 ? ( + 同步中 + ) : ( + + + 真实数据 + + )} +
+ +
+
+
+ + 记录 / AI +
+
+ {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 -[![Docker](https://img.shields.io/badge/Docker%20Hub-0.3.6-blue?logo=docker)](https://hub.docker.com/u/xjh1994) +[![Docker](https://img.shields.io/badge/Docker%20Hub-0.3.6-blue?logo=docker)](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) **仪表盘** -dashboard +dashboard **Agent 节点自动路由** clipboard-image-1774003758 @@ -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> + showToast: ShowToast +}) { + const { + attachToParent, + clearHelperLines, + detachFromParent, + disconnectNodeConnections, + getInternalNode, + resizeGroupToFit, + resolveNodeCollisions, + shakeRef, + showToast, + } = options + + const onNodeDrag: OnNodeDrag = useCallback( + (_event, node) => { + const current = shakeRef.current.get(node.id) ?? { + lastX: node.position.x, + lastDirection: 0, + turns: 0, + disconnected: false, + } + const delta = node.position.x - current.lastX + if (Math.abs(delta) < 14) { + shakeRef.current.set(node.id, { ...current, lastX: node.position.x }) + return + } + const direction = delta > 0 ? 1 : -1 + const turns = current.lastDirection !== 0 && current.lastDirection !== direction ? current.turns + 1 : current.turns + const next = { lastX: node.position.x, lastDirection: direction as -1 | 1, turns, disconnected: current.disconnected } + if (!next.disconnected && turns >= 4) { + const removed = disconnectNodeConnections(node.id) + if (removed > 0) { + next.disconnected = true + showToast(`已断开 ${removed} 条连接`) + } + } + shakeRef.current.set(node.id, next) + }, + [disconnectNodeConnections, shakeRef, showToast], + ) + + const onNodeDragStop = useCallback( + (_event: unknown, node: Node) => { + clearHelperLines() + shakeRef.current.delete(node.id) + if (node.type === "group") { + resizeGroupToFit(node.id) + return + } + + const internal = getInternalNode(node.id) + const abs = internal?.internals?.positionAbsolute ?? node.position + const width = node.measured?.width ?? 240 + const height = node.measured?.height ?? 96 + const center = { x: abs.x + width / 2, y: abs.y + height / 2 } + const workflowNode = node as WorkflowNode + const targetGroup = useFlowStore.getState().nodes.find((candidate) => { + if (candidate.type !== "group" || candidate.data.collapsed || candidate.id === node.id) return false + const groupWidth = (candidate.width as number) ?? candidate.measured?.width ?? 320 + const groupHeight = (candidate.height as number) ?? candidate.measured?.height ?? 220 + return center.x >= candidate.position.x && center.x <= candidate.position.x + groupWidth && center.y >= candidate.position.y && center.y <= candidate.position.y + groupHeight + }) + + if (targetGroup && workflowNode.parentId !== targetGroup.id) attachToParent(node.id, targetGroup.id) + else if (!targetGroup && workflowNode.parentId) detachFromParent(node.id) + else if (targetGroup && workflowNode.parentId === targetGroup.id) resizeGroupToFit(targetGroup.id) + resolveNodeCollisions(node.id) + }, + [attachToParent, clearHelperLines, detachFromParent, getInternalNode, resizeGroupToFit, resolveNodeCollisions, shakeRef], + ) + + return { onNodeDrag, onNodeDragStop } +} + +export function useConnectionGuards(options: { + settings: Pick + showToast: ShowToast +}) { + const { settings, showToast } = options + const isValidConnection: IsValidConnection = useCallback( + (connection) => { + const res = validateConnection(useFlowStore.getState().edges, { + source: connection.source, + target: connection.target, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + }, { + preventCycles: settings.preventCycles, + maxSourceConnections: settings.maxSourceConnections, + maxTargetConnections: settings.maxTargetConnections, + typedHandles: settings.typedHandles, + nodes: useFlowStore.getState().nodes, + }) + if (res.ok) return true + showToast(res.reason) + return false + }, + [settings.maxSourceConnections, settings.maxTargetConnections, settings.preventCycles, settings.typedHandles, showToast], + ) + + const onBeforeDelete: OnBeforeDelete = useCallback( + async ({ nodes, edges }) => { + if (!settings.confirmDelete) return { nodes, edges } + const count = nodes.length + edges.length + if (count === 0) return false + return window.confirm(`确认删除 ${nodes.length} 个节点 / ${edges.length} 条连线?`) ? { nodes, edges } : false + }, + [settings.confirmDelete], + ) + + return { isValidConnection, onBeforeDelete } +} + +export function useCanvasViewportCompaction(options: { + compactViewport: boolean + nodes: WorkflowNode[] + setViewport: (viewport: { x: number; y: number; zoom: number }, options?: { duration?: number }) => void +}) { + const { compactViewport, nodes, setViewport } = options + const applyCompactViewport = useCallback(() => { + if (!compactViewport || nodes.length === 0) return undefined + const leftMost = Math.min(...nodes.map((node) => node.position.x)) + const topMost = Math.min(...nodes.map((node) => node.position.y)) + const zoom = 0.62 + return window.setTimeout(() => { + setViewport({ x: 18 - leftMost * zoom, y: 220 - topMost * zoom, zoom }, { duration: 0 }) + }, 120) + }, [compactViewport, nodes, setViewport]) + return applyCompactViewport +} diff --git a/frontend/components/flow/workflow-canvas-surface.tsx b/frontend/components/flow/workflow-canvas-surface.tsx new file mode 100644 index 0000000..48685df --- /dev/null +++ b/frontend/components/flow/workflow-canvas-surface.tsx @@ -0,0 +1,361 @@ +"use client" + +import { useEffect, type DragEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react" +import { + Background, + BackgroundVariant, + Controls, + MiniMap, + ReactFlow, + SelectionMode, + useStore, + type IsValidConnection, + type NodeMouseHandler, + type OnBeforeDelete, + type OnConnect, + type OnEdgesChange, + type OnNodeDrag, + type OnNodesChange, +} from "@xyflow/react" + +import type { CanvasSettings } from "@/lib/flow/settings-store" +import type { FlowState } from "@/lib/flow/store" +import type { ToolMode, WorkflowEdge, WorkflowNode } from "@/lib/flow/types" +import type { WorkflowCapabilitiesResponse } from "@/lib/workflow/capabilities" +import type { WorkflowNodeCatalogItem } from "@/lib/workflow/node-catalog" +import type { WorkflowPrimitive } from "@/lib/workflow/node-primitives" +import type { AgentProposal } from "@/lib/workflow/proposal" +import type { ProposalFocusTarget } from "@/lib/workflow/proposal-focus" +import { cn } from "@/lib/utils" +import { AgentDrawer } from "./agent-drawer" +import { Collaboration } from "./collaboration" +import { DrawingLayer } from "./drawing-layer" +import EditableEdge from "./edges/editable-edge" +import RoutedEdge from "./edges/routed-edge" +import WorkflowEdge_ from "./edges/workflow-edge" +import { HelperLinesRenderer } from "./helper-lines-renderer" +import { NodeContextMenu } from "./node-context-menu" +import GroupNode from "./nodes/group-node" +import MathNode from "./nodes/math-node" +import NoteNode from "./nodes/note-node" +import ShapeNode from "./nodes/shape-node" +import WorkflowNodeComp from "./nodes/workflow-node" +import type { NodeMenuState } from "./workflow-node-menu-actions" +import { + NetworkBreadcrumb, + ScissorTrailOverlay, + WorkflowFloatingPanels, + WorkflowToast, +} from "./workflow-editor-overlays" +import { WorkflowMotionRuntime } from "./workflow-motion-runtime" +import type { CanvasPoint } from "./workflow-canvas-geometry" + +const nodeTypes = { + workflow: WorkflowNodeComp, + note: NoteNode, + group: GroupNode, + shape: ShapeNode, + math: MathNode, +} + +const edgeTypes = { + workflow: WorkflowEdge_, + editable: EditableEdge, + routed: RoutedEdge, +} + +type PrimitiveMenuGroup = { + category: string + label: string + items: WorkflowPrimitive[] +} + +type WorkflowCanvasSurfaceProps = { + acceptProposal: (proposal: AgentProposal) => void + addDopNodeFromMenu: (item: WorkflowNodeCatalogItem) => void + addPrimitiveFromMenu: (item: WorkflowPrimitive, itemIndex: number) => void + agentDrawerOpen: boolean + agentProposal: AgentProposal | undefined + capabilities: WorkflowCapabilitiesResponse | null | undefined + compactViewport: boolean + diveIntoNetwork: (nodeId: string) => void + dopNodeMenuItems: WorkflowNodeCatalogItem[] + edges: WorkflowEdge[] + exitCurrentNetwork: () => void + focusProposalOperation: (focus: ProposalFocusTarget) => void + helperLines: FlowState["helperLines"] + inspectorOpen: boolean + isDraw: boolean + isScissors: boolean + isValidConnection: IsValidConnection + lockInternals: (nodeId: string) => void + networkLocked: boolean + networkStack: FlowState["networkStack"] + nodeManagementOpen: boolean + nodeMenu: NodeMenuState | null + nodes: WorkflowNode[] + onBeforeDelete: OnBeforeDelete + onCanvasMouseDownCapture: (event: ReactMouseEvent) => void + onCanvasMouseMoveCapture: (event: ReactMouseEvent) => void + onCanvasMouseUpCapture: (event: ReactMouseEvent) => void + onConnect: OnConnect + onDragOver: (event: DragEvent) => void + onDrop: (event: DragEvent) => void + onEdgesChange: OnEdgesChange + onMouseMove: (event: ReactMouseEvent) => void + onNodeContextMenu: NodeMouseHandler + onNodeDoubleClick: NodeMouseHandler + onNodeDrag: OnNodeDrag + onNodeDragStop: OnNodeDrag + onNodesChange: OnNodesChange + onProfileChange: FlowState["updateWorkflowProfile"] + primitiveMenuGroups: PrimitiveMenuGroup[] + projectSettingsOpen: boolean + rejectProposal: () => void + runTraceOpen: boolean + scissorTrail: CanvasPoint[] + selectComponentFromMenu: (nodeId: string) => void + setAgentDrawerOpen: (open: boolean) => void + setNodeManagementOpen: (open: boolean) => void + settings: CanvasSettings + settingsOpen: boolean + showNodeInfo: () => void + showParameters: () => void + takeSnapshot: () => void + toast: string | null + toolMode: ToolMode + unlockInternals: (nodeId: string) => void + workflowProfile: FlowState["workflowProject"]["profile"] + wrapperRef: RefObject + zoom: number + setZoom: (zoom: number) => void +} + +function minimapNodeColor(node: { selected?: boolean }) { + return node.selected ? "#e8e8e6" : "#3a3d42" +} + +function zoomBucket(zoom: number) { + if (zoom < 0.5) return "low" + if (zoom > 1.4) return "high" + return "mid" +} + +function panOnDragValue(settings: CanvasSettings, interactionLocked: boolean) { + return settings.panOnDrag && !interactionLocked ? [1, 2] : false +} + +function flowInteractionProps(settings: CanvasSettings, interactionLocked: boolean) { + if (settings.touchMode) { + return { panOnDrag: [1, 2] as number[], panOnScroll: true, selectionOnDrag: false } + } + return { + panOnDrag: panOnDragValue(settings, interactionLocked), + panOnScroll: settings.panOnScroll && !interactionLocked, + selectionOnDrag: settings.selectionOnDrag && !interactionLocked, + } +} + +/** Live zoom bridge so nodes can render at different detail levels. */ +function ZoomProvider({ onZoom }: { onZoom: (z: number) => void }) { + const zoom = useStore((s) => s.transform[2]) + useEffect(() => { + onZoom(zoom) + }, [zoom, onZoom]) + return null +} + +function OptionalBackground({ visible }: { visible: boolean }) { + if (!visible) return null + return +} + +function OptionalControls({ visible }: { visible: boolean }) { + if (!visible) return null + return +} + +function OptionalMiniMap({ visible }: { visible: boolean }) { + if (!visible) return null + return ( + + ) +} + +function NodeMenuOverlay({ + addDopNodeFromMenu, + addPrimitiveFromMenu, + capabilities, + diveIntoNetwork, + dopNodeMenuItems, + lockInternals, + menu, + primitiveMenuGroups, + selectComponentFromMenu, + settings, + showNodeInfo, + showParameters, + unlockInternals, + wrapperElement, +}: { + addDopNodeFromMenu: (item: WorkflowNodeCatalogItem) => void + addPrimitiveFromMenu: (item: WorkflowPrimitive, itemIndex: number) => void + capabilities: WorkflowCapabilitiesResponse | null | undefined + diveIntoNetwork: (nodeId: string) => void + dopNodeMenuItems: WorkflowNodeCatalogItem[] + lockInternals: (nodeId: string) => void + menu: NodeMenuState | null + primitiveMenuGroups: PrimitiveMenuGroup[] + selectComponentFromMenu: (nodeId: string) => void + settings: CanvasSettings + showNodeInfo: () => void + showParameters: () => void + unlockInternals: (nodeId: string) => void + wrapperElement: HTMLElement | null +}) { + if (!menu) return null + return ( + + ) +} + +function CanvasLayers({ + helperLines, + settings, + setZoom, +}: { + helperLines: FlowState["helperLines"] + settings: CanvasSettings + setZoom: (zoom: number) => void +}) { + return ( + <> + + + + + + + + + + ) +} + +export function WorkflowCanvasSurface(props: WorkflowCanvasSurfaceProps) { + const interactionLocked = props.isDraw || props.isScissors + const flowInteraction = flowInteractionProps(props.settings, interactionLocked) + return ( +
+ + + + + + + + + + props.setNodeManagementOpen(false)} + onProfileChange={props.onProfileChange} + projectSettingsOpen={props.projectSettingsOpen} + runTraceOpen={props.runTraceOpen} + settingsOpen={props.settingsOpen} + workflowProfile={props.workflowProfile} + /> + + props.setAgentDrawerOpen(false)} + /> + + +
+ ) +} diff --git a/frontend/components/flow/workflow-editor-effects.ts b/frontend/components/flow/workflow-editor-effects.ts new file mode 100644 index 0000000..c670b66 --- /dev/null +++ b/frontend/components/flow/workflow-editor-effects.ts @@ -0,0 +1,106 @@ +"use client" + +import { useCallback, useEffect, type Dispatch, type SetStateAction } from "react" + +import { useFlowStore } from "@/lib/flow/store" +import { loadShareStateFromUrl } from "@/lib/flow/share-state" +import type { WorkflowCapabilitiesResponse } from "@/lib/workflow/capabilities" +import type { WorkflowNode } from "@/lib/flow/types" +import type { NodeMenuState } from "./workflow-node-menu-actions" + +type FitView = (options?: { padding?: number; duration?: number; nodes?: { id: string }[] }) => unknown +type ShowToast = (message: string) => void + +export function isNetworkLocked(networkStack: { nodeId: string; label: string }[], nodes: WorkflowNode[]) { + return networkStack.length > 0 && nodes.some((node) => node.data.internalLocked === true) +} + +export function useApplyWorkflowCapabilities(options: { + applyWorkflowCapabilities: (capabilities: WorkflowCapabilitiesResponse) => void + capabilities: WorkflowCapabilitiesResponse | null | undefined + workflowProjectId: string +}) { + const { applyWorkflowCapabilities, capabilities, workflowProjectId } = options + useEffect(() => { + if (capabilities) applyWorkflowCapabilities(capabilities) + }, [applyWorkflowCapabilities, capabilities, workflowProjectId]) +} + +export function useSharedWorkflowImport(options: { + fitView: FitView + showToast: ShowToast +}) { + const { fitView, showToast } = options + useEffect(() => { + if (typeof window === "undefined") return + const shared = loadShareStateFromUrl(window.location.href) + if (!shared) return + useFlowStore.setState({ + workflowProject: shared.workflowProject, + nodes: shared.nodes, + edges: shared.edges, + drawings: shared.drawings ?? [], + networkStack: [], + helperLines: { snapPosition: {} }, + }) + showToast("已从分享 URL 恢复 workflow") + window.setTimeout(() => void fitView({ padding: 0.24, duration: 220 }), 30) + }, [fitView, showToast]) +} + +export function useAutoDismissToast(toast: string | null, setToast: Dispatch>) { + useEffect(() => { + if (!toast) return + const timer = setTimeout(() => setToast(null), 2200) + return () => clearTimeout(timer) + }, [setToast, toast]) +} + +export function useDismissNodeMenu( + nodeMenu: NodeMenuState | null, + setNodeMenu: Dispatch>, +) { + useEffect(() => { + if (!nodeMenu) return + const close = () => setNodeMenu(null) + window.addEventListener("click", close) + window.addEventListener("keydown", close) + return () => { + window.removeEventListener("click", close) + window.removeEventListener("keydown", close) + } + }, [nodeMenu, setNodeMenu]) +} + +export function useCompactViewportMedia(setCompactViewport: Dispatch>) { + useEffect(() => { + const media = window.matchMedia("(max-width: 640px)") + const update = () => setCompactViewport(media.matches) + update() + media.addEventListener("change", update) + return () => media.removeEventListener("change", update) + }, [setCompactViewport]) +} + +export function useCompactViewportEffect(applyCompactViewport: () => number | undefined) { + useEffect(() => { + const timer = applyCompactViewport() + return () => { + if (timer) window.clearTimeout(timer) + } + }, [applyCompactViewport]) +} + +export function useExitCurrentNetwork(options: { + exitNodeNetwork: () => boolean + fitView: FitView + showToast: ShowToast +}) { + const { exitNodeNetwork, fitView, showToast } = options + return useCallback(() => { + if (exitNodeNetwork()) { + showToast("已返回上一层 Network") + window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) + } + }, [exitNodeNetwork, fitView, showToast]) +} diff --git a/frontend/components/flow/workflow-editor-overlays.tsx b/frontend/components/flow/workflow-editor-overlays.tsx new file mode 100644 index 0000000..903a945 --- /dev/null +++ b/frontend/components/flow/workflow-editor-overlays.tsx @@ -0,0 +1,128 @@ +import { Inspector } from "./inspector" +import { InteractionSettingsPanel } from "./interaction-settings-panel" +import { ProjectSettingsPanel } from "./project-settings-panel" +import { RunTracePanel } from "./run-trace-panel" +import { NodeManagementPanel } from "./node-management-panel" +import { cn } from "@/lib/utils" +import type { CanvasPoint } from "./workflow-canvas-geometry" +import type { WorkflowProfile } from "@/lib/workflow/schema" + +type NetworkStackEntry = { nodeId: string; label: string } + +export function ScissorTrailOverlay({ active, points }: { active: boolean; points: CanvasPoint[] }) { + if (!active || points.length <= 1) return null + const polylinePoints = points.map((point) => `${point.x},${point.y}`).join(" ") + return ( + + + + + ) +} + +export function NetworkBreadcrumb({ + locked, + networkStack, + onExit, +}: { + locked: boolean + networkStack: NetworkStackEntry[] + onExit: () => void +}) { + if (networkStack.length === 0) return null + return ( +
+ + / + obj + {networkStack.map((entry) => ( + + / + {entry.label} + + ))} + + {locked ? "LOCKED" : "DRAFT"} + + Esc / Backspace +
+ ) +} + +export function WorkflowFloatingPanels({ + inspectorOpen, + nodeManagementOpen, + onCloseNodeManagement, + onProfileChange, + projectSettingsOpen, + runTraceOpen, + settingsOpen, + workflowProfile, +}: { + inspectorOpen: boolean + nodeManagementOpen: boolean + onCloseNodeManagement: () => void + onProfileChange: (profile: WorkflowProfile) => void + projectSettingsOpen: boolean + runTraceOpen: boolean + settingsOpen: boolean + workflowProfile: WorkflowProfile +}) { + return ( + <> + {runTraceOpen ? ( +
+ +
+ ) : null} + + {nodeManagementOpen ? : null} + + {projectSettingsOpen ? ( +
+ +
+ ) : settingsOpen ? ( +
+ +
+ ) : inspectorOpen ? ( +
+ +
+ ) : null} + + ) +} + +export function WorkflowToast({ message }: { message: string | null }) { + if (!message) return null + return ( +
+ {message} +
+ ) +} diff --git a/frontend/components/flow/workflow-editor-selectors.ts b/frontend/components/flow/workflow-editor-selectors.ts new file mode 100644 index 0000000..abed9de --- /dev/null +++ b/frontend/components/flow/workflow-editor-selectors.ts @@ -0,0 +1,49 @@ +import type { FlowState } from "@/lib/flow/store" + +export function selectEditorCanvasState(state: FlowState) { + return { + addNodeFromPalette: state.addNodeFromPalette, + addPrimitiveNode: state.addPrimitiveNode, + addWorkflowNodeFromCatalog: state.addWorkflowNodeFromCatalog, + applyWorkflowCapabilities: state.applyWorkflowCapabilities, + attachToParent: state.attachToParent, + autoLayout: state.autoLayout, + clearHelperLines: state.clearHelperLines, + clearPendingAgentProposal: state.clearPendingAgentProposal, + clearProposalFocus: state.clearProposalFocus, + copy: state.copy, + cut: state.cut, + deleteSelected: state.deleteSelected, + detachFromParent: state.detachFromParent, + disconnectNodeConnections: state.disconnectNodeConnections, + duplicate: state.duplicateSelected, + edges: state.edges, + enterNodeNetwork: state.enterNodeNetwork, + exitNodeNetwork: state.exitNodeNetwork, + focusProposalTargets: state.focusProposalTargets, + groupSelection: state.groupSelection, + helperLines: state.helperLines, + importWorkflowProject: state.importWorkflowProject, + lockNodeInternals: state.lockNodeInternals, + networkStack: state.networkStack, + nodes: state.nodes, + onConnect: state.onConnect, + onEdgesChange: state.onEdgesChange, + onNodesChange: state.onNodesChange, + paste: state.paste, + pendingAgentProposal: state.pendingAgentProposal, + redo: state.redo, + removeEdgesByIds: state.removeEdgesByIds, + resizeGroupToFit: state.resizeGroupToFit, + resolveNodeCollisions: state.resolveNodeCollisions, + save: state.save, + selectConnectedComponent: state.selectConnectedComponent, + setToolMode: state.setToolMode, + takeSnapshot: state.takeSnapshot, + toolMode: state.toolMode, + undo: state.undo, + unlockNodeInternals: state.unlockNodeInternals, + updateWorkflowProfile: state.updateWorkflowProfile, + workflowProject: state.workflowProject, + } +} diff --git a/frontend/components/flow/workflow-editor.tsx b/frontend/components/flow/workflow-editor.tsx index 4d2b608..6d7ed27 100644 --- a/frontend/components/flow/workflow-editor.tsx +++ b/frontend/components/flow/workflow-editor.tsx @@ -1,175 +1,94 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from "react" -import { - ReactFlow, - ReactFlowProvider, - Background, - BackgroundVariant, - Controls, - MiniMap, - SelectionMode, - useReactFlow, - useStore, - type IsValidConnection, - type Node, - type OnBeforeDelete, - type OnNodeDrag, -} from "@xyflow/react" +import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react" +import { ReactFlowProvider, useReactFlow, type NodeMouseHandler } from "@xyflow/react" import "@xyflow/react/dist/style.css" import { useFlowStore } from "@/lib/flow/store" import { useSettingsStore } from "@/lib/flow/settings-store" -import { validateConnection } from "@/lib/flow/graph" -import type { PaletteItem, WorkflowNode, WorkflowEdge, ToolMode } from "@/lib/flow/types" -import { Inspector } from "./inspector" +import type { WorkflowNode, WorkflowEdge, ToolMode } from "@/lib/flow/types" import { CommandStrip } from "./command-strip" import { CommandPalette } from "./command-palette" -import { HelperLinesRenderer } from "./helper-lines-renderer" -import { WorkflowMotionRuntime } from "./workflow-motion-runtime" -import { DrawingLayer } from "./drawing-layer" -import { Collaboration } from "./collaboration" -import WorkflowNodeComp from "./nodes/workflow-node" -import NoteNode from "./nodes/note-node" -import GroupNode from "./nodes/group-node" -import ShapeNode from "./nodes/shape-node" -import MathNode from "./nodes/math-node" -import WorkflowEdge_ from "./edges/workflow-edge" -import EditableEdge from "./edges/editable-edge" -import RoutedEdge from "./edges/routed-edge" -import { InteractionSettingsPanel } from "./interaction-settings-panel" -import { ProjectSettingsPanel } from "./project-settings-panel" -import { RunTracePanel } from "./run-trace-panel" -import { AgentDrawer } from "./agent-drawer" -import { NodeManagementPanel } from "./node-management-panel" -import { acceptAgentProposal, type AgentProposal } from "@/lib/workflow/proposal" -import type { ProposalFocusTarget } from "@/lib/workflow/proposal-focus" -import { getWorkflowNodeCatalog, type WorkflowNodeCatalogItem } from "@/lib/workflow/node-catalog" -import { getWorkflowPrimitives, type WorkflowPrimitive } from "@/lib/workflow/node-primitives" +import { getWorkflowNodeCatalog } from "@/lib/workflow/node-catalog" +import { getWorkflowPrimitives } from "@/lib/workflow/node-primitives" import { groupPrimitivesForNodeMenu } from "@/lib/workflow/node-menu" -import { localizeNodeText } from "@/lib/workflow/node-i18n" -import { getNodeVisualSignature } from "@/lib/workflow/node-visuals" -import { primitiveRuntimeCapability, runtimeStatusLabel, runtimeStatusTone } from "@/lib/workflow/capabilities" import { useWorkflowCapabilities } from "@/lib/workflow/use-workflow-capabilities" -import { loadShareStateFromUrl } from "@/lib/flow/share-state" -import { cn } from "@/lib/utils" - -const nodeTypes = { - workflow: WorkflowNodeComp, - note: NoteNode, - group: GroupNode, - shape: ShapeNode, - math: MathNode, -} - -const edgeTypes = { - workflow: WorkflowEdge_, - editable: EditableEdge, - routed: RoutedEdge, -} - -type ShakeState = { - lastX: number - lastDirection: -1 | 0 | 1 - turns: number - disconnected: boolean -} - -function distance(a: { x: number; y: number }, b: { x: number; y: number }) { - return Math.hypot(a.x - b.x, a.y - b.y) -} - -function edgeIdsAtScreenPoint(point: { x: number; y: number }, 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 -} - -function localPoint(element: HTMLElement | null, event: ReactMouseEvent) { - const rect = element?.getBoundingClientRect() - if (!rect) return { x: event.clientX, y: event.clientY } - return { x: event.clientX - rect.left, y: event.clientY - rect.top } -} - -function minimapNodeColor(node: { selected?: boolean }) { - return node.selected ? "#e8e8e6" : "#3a3d42" -} - -/** Live zoom bridge so nodes can render at different detail levels. */ -function ZoomProvider({ onZoom }: { onZoom: (z: number) => void }) { - const zoom = useStore((s) => s.transform[2]) - useEffect(() => { - onZoom(zoom) - }, [zoom, onZoom]) - return null +import { useWorkflowKeyboardShortcuts } from "./workflow-keyboard-shortcuts" +import { + useCanvasViewportCompaction, + useConnectionGuards, + usePaletteDrop, + useScissorCanvasHandlers, + useWorkflowNodeDragHandlers, + type ShakeState, +} from "./workflow-canvas-interactions" +import { useWorkflowNodeMenuActions, type NodeMenuState } from "./workflow-node-menu-actions" +import { useWorkflowAgentProposal } from "./workflow-agent-proposal" +import { selectEditorCanvasState } from "./workflow-editor-selectors" +import { WorkflowCanvasSurface } from "./workflow-canvas-surface" +import { + isNetworkLocked, + useApplyWorkflowCapabilities, + useAutoDismissToast, + useCompactViewportEffect, + useCompactViewportMedia, + useDismissNodeMenu, + useExitCurrentNetwork, + useSharedWorkflowImport, +} from "./workflow-editor-effects" + +function buildPrimitiveMenuGroups() { + return groupPrimitivesForNodeMenu(getWorkflowPrimitives()) } function EditorCanvas() { - const nodes = useFlowStore((s) => s.nodes) - const edges = useFlowStore((s) => s.edges) - const onNodesChange = useFlowStore((s) => s.onNodesChange) - const onEdgesChange = useFlowStore((s) => s.onEdgesChange) - const onConnect = useFlowStore((s) => s.onConnect) - const helperLines = useFlowStore((s) => s.helperLines) - const takeSnapshot = useFlowStore((s) => s.takeSnapshot) - const clearHelperLines = useFlowStore((s) => s.clearHelperLines) - const addNodeFromPalette = useFlowStore((s) => s.addNodeFromPalette) - const undo = useFlowStore((s) => s.undo) - const redo = useFlowStore((s) => s.redo) - const copy = useFlowStore((s) => s.copy) - const paste = useFlowStore((s) => s.paste) - const cut = useFlowStore((s) => s.cut) - const duplicate = useFlowStore((s) => s.duplicateSelected) - const deleteSelected = useFlowStore((s) => s.deleteSelected) - const autoLayout = useFlowStore((s) => s.autoLayout) - const disconnectNodeConnections = useFlowStore((s) => s.disconnectNodeConnections) - const removeEdgesByIds = useFlowStore((s) => s.removeEdgesByIds) - const selectConnectedComponent = useFlowStore((s) => s.selectConnectedComponent) - const unlockNodeInternals = useFlowStore((s) => s.unlockNodeInternals) - const lockNodeInternals = useFlowStore((s) => s.lockNodeInternals) - const enterNodeNetwork = useFlowStore((s) => s.enterNodeNetwork) - const exitNodeNetwork = useFlowStore((s) => s.exitNodeNetwork) - const networkStack = useFlowStore((s) => s.networkStack) - const groupSelection = useFlowStore((s) => s.groupSelection) - const attachToParent = useFlowStore((s) => s.attachToParent) - const detachFromParent = useFlowStore((s) => s.detachFromParent) - const resolveNodeCollisions = useFlowStore((s) => s.resolveNodeCollisions) - const resizeGroupToFit = useFlowStore((s) => s.resizeGroupToFit) - const toolMode = useFlowStore((s) => s.toolMode) - const save = useFlowStore((s) => s.save) - const workflowProject = useFlowStore((s) => s.workflowProject) - const importWorkflowProject = useFlowStore((s) => s.importWorkflowProject) - const applyWorkflowCapabilities = useFlowStore((s) => s.applyWorkflowCapabilities) - const updateWorkflowProfile = useFlowStore((s) => s.updateWorkflowProfile) - const focusProposalTargets = useFlowStore((s) => s.focusProposalTargets) - const clearProposalFocus = useFlowStore((s) => s.clearProposalFocus) - const pendingAgentProposal = useFlowStore((s) => s.pendingAgentProposal) - const clearPendingAgentProposal = useFlowStore((s) => s.clearPendingAgentProposal) - const addWorkflowNodeFromCatalog = useFlowStore((s) => s.addWorkflowNodeFromCatalog) - const addPrimitiveNode = useFlowStore((s) => s.addPrimitiveNode) + const { + addNodeFromPalette, + addPrimitiveNode, + addWorkflowNodeFromCatalog, + applyWorkflowCapabilities, + attachToParent, + autoLayout, + clearHelperLines, + clearPendingAgentProposal, + clearProposalFocus, + copy, + cut, + deleteSelected, + detachFromParent, + disconnectNodeConnections, + duplicate, + edges, + enterNodeNetwork, + exitNodeNetwork, + focusProposalTargets, + groupSelection, + helperLines, + importWorkflowProject, + lockNodeInternals, + networkStack, + nodes, + onConnect, + onEdgesChange, + onNodesChange, + paste, + pendingAgentProposal, + redo, + removeEdgesByIds, + resizeGroupToFit, + resolveNodeCollisions, + save, + selectConnectedComponent, + setToolMode, + takeSnapshot, + toolMode, + undo, + unlockNodeInternals, + updateWorkflowProfile, + workflowProject, + } = useFlowStore(selectEditorCanvasState) const settings = useSettingsStore() - const setToolMode = useFlowStore((s) => s.setToolMode) const { screenToFlowPosition, getInternalNode, setViewport, fitView } = useReactFlow() const wrapperRef = useRef(null) @@ -186,532 +105,142 @@ function EditorCanvas() { const [projectSettingsOpen, setProjectSettingsOpen] = useState(false) const [runTraceOpen, setRunTraceOpen] = useState(false) const [agentDrawerOpen, setAgentDrawerOpen] = useState(false) - const [agentProposal, setAgentProposal] = useState(undefined) const [nodeManagementOpen, setNodeManagementOpen] = useState(false) const [zoom, setZoom] = useState(1) const [compactViewport, setCompactViewport] = useState(false) - const [nodeMenu, setNodeMenu] = useState<{ nodeId: string; x: number; y: number } | null>(null) + const [nodeMenu, setNodeMenu] = useState(null) const { capabilities } = useWorkflowCapabilities(true) const dopNodeMenuItems = useMemo( () => getWorkflowNodeCatalog(workflowProject.profile, capabilities), [workflowProject.profile, capabilities], ) - const primitiveMenuGroups = useMemo(() => groupPrimitivesForNodeMenu(getWorkflowPrimitives()), []) + const [primitiveMenuGroups] = useState(buildPrimitiveMenuGroups) const showToast = useCallback((msg: string) => setToast(msg), []) + const setMiniMapVisible = useCallback((visible: boolean) => settings.set("showMiniMap", visible), [settings]) - useEffect(() => { - if (capabilities) applyWorkflowCapabilities(capabilities) - }, [applyWorkflowCapabilities, capabilities, workflowProject.id]) - - useEffect(() => { - if (typeof window === "undefined") return - const shared = loadShareStateFromUrl(window.location.href) - if (!shared) return - useFlowStore.setState({ - workflowProject: shared.workflowProject, - nodes: shared.nodes, - edges: shared.edges, - drawings: shared.drawings ?? [], - networkStack: [], - helperLines: { snapPosition: {} }, - }) - showToast("已从分享 URL 恢复 workflow") - window.setTimeout(() => void fitView({ padding: 0.24, duration: 220 }), 30) - }, [fitView, showToast]) - - useEffect(() => { - if (!toast) return - const t = setTimeout(() => setToast(null), 2200) - return () => clearTimeout(t) - }, [toast]) + useApplyWorkflowCapabilities({ applyWorkflowCapabilities, capabilities, workflowProjectId: workflowProject.id }) + useSharedWorkflowImport({ fitView, showToast }) + useAutoDismissToast(toast, setToast) + useDismissNodeMenu(nodeMenu, setNodeMenu) + useCompactViewportMedia(setCompactViewport) + const applyCompactViewport = useCanvasViewportCompaction({ compactViewport, nodes, setViewport }) + useCompactViewportEffect(applyCompactViewport) - useEffect(() => { - if (!nodeMenu) return - const close = () => setNodeMenu(null) - window.addEventListener("click", close) - window.addEventListener("keydown", close) - return () => { - window.removeEventListener("click", close) - window.removeEventListener("keydown", close) - } - }, [nodeMenu]) - - useEffect(() => { - const media = window.matchMedia("(max-width: 640px)") - const update = () => setCompactViewport(media.matches) - update() - media.addEventListener("change", update) - return () => media.removeEventListener("change", update) - }, []) - - useEffect(() => { - if (!compactViewport || nodes.length === 0) return - const leftMost = Math.min(...nodes.map((node) => node.position.x)) - const topMost = Math.min(...nodes.map((node) => node.position.y)) - const zoom = 0.62 - const timer = window.setTimeout(() => { - setViewport({ x: 18 - leftMost * zoom, y: 220 - topMost * zoom, zoom }, { duration: 0 }) - }, 120) - return () => window.clearTimeout(timer) - }, [compactViewport, nodes.length, setViewport]) - - useEffect(() => { - const isEditableTarget = (target: EventTarget | null) => { - const element = target as HTMLElement | null - if (!element) return false - return element.tagName === "INPUT" || element.tagName === "TEXTAREA" || element.isContentEditable - } - - const onKeyDown = (e: KeyboardEvent) => { - const mod = e.metaKey || e.ctrlKey - if (mod && e.key.toLowerCase() === "k") { - e.preventDefault() - setPaletteOpen((o) => !o) - return - } - if (isEditableTarget(e.target)) return - - if (e.key === "Tab" || e.key.toLowerCase() === "b") { - if (!mod) { - e.preventDefault() - setPaletteOpen(true) - return - } - } - if (mod && e.key.toLowerCase() === "s") { - e.preventDefault() - save() - showToast("已保存到本地") - } else if (mod && e.key.toLowerCase() === "z" && !e.shiftKey) { - e.preventDefault() - undo() - } else if (mod && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { - e.preventDefault() - redo() - } else if (mod && e.key.toLowerCase() === "c") { - copy() - } else if (mod && e.key.toLowerCase() === "v") { - e.preventDefault() - paste(screenToFlowPosition(mousePos.current)) - } else if (mod && e.key.toLowerCase() === "x") { - cut() - } else if (mod && e.key.toLowerCase() === "d") { - e.preventDefault() - duplicate() - } else if (mod && e.key.toLowerCase() === "g") { - e.preventDefault() - groupSelection() - } else if (!mod && e.key.toLowerCase() === "o") { - e.preventDefault() - const next = !useSettingsStore.getState().showMiniMap - settings.set("showMiniMap", next) - showToast(next ? "节点缩略图已显示" : "节点缩略图已隐藏") - } else if (!mod && e.key.toLowerCase() === "p") { - e.preventDefault() - if (settingsOpen || projectSettingsOpen) { - setSettingsOpen(false) - setProjectSettingsOpen(false) - setInspectorOpen(true) - showToast("Parameter Interface 已显示") - return - } - setInspectorOpen((open) => { - const next = !open - showToast(next ? "Parameter Interface 已显示" : "Parameter Interface 已隐藏") - return next - }) - } else if (!mod && e.key.toLowerCase() === "h") { - e.preventDefault() - if (useFlowStore.getState().nodes.length === 0) return - void fitView({ padding: 0.24, duration: 220 }) - showToast("已显示全部节点") - } else if (!mod && e.key.toLowerCase() === "l") { - e.preventDefault() - if (useFlowStore.getState().nodes.length === 0) return - showToast("正在自动排布节点") - void autoLayout("TB", "elk", true).then(() => { - showToast("已自动排布整体节点") - window.setTimeout(() => void fitView({ padding: 0.24, duration: 260 }), 30) - }) - } else if (!mod && (e.key === "Escape" || e.key === "Backspace") && useFlowStore.getState().networkStack.length > 0) { - e.preventDefault() - if (exitNodeNetwork()) { - showToast("已返回上一层 Network") - window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) - } - } else if (!mod && e.key.toLowerCase() === "y") { - e.preventDefault() - if (e.repeat) return - yMomentaryModeRef.current = useFlowStore.getState().toolMode - setToolMode("scissors") - } else if (e.key === "Delete" || e.key === "Backspace") { - deleteSelected() - } - } - - const onKeyUp = (e: KeyboardEvent) => { - if (isEditableTarget(e.target)) return - if (e.metaKey || e.ctrlKey || e.key.toLowerCase() !== "y") return - if (yMomentaryModeRef.current === null) return - e.preventDefault() - const restoreMode = yMomentaryModeRef.current - yMomentaryModeRef.current = null - scissorDraggingRef.current = false - scissorCutRef.current = new Set() - setScissorTrail([]) - setToolMode(restoreMode) - } - - window.addEventListener("keydown", onKeyDown) - window.addEventListener("keyup", onKeyUp) - return () => { - window.removeEventListener("keydown", onKeyDown) - window.removeEventListener("keyup", onKeyUp) - } - }, [ - undo, - redo, + useWorkflowKeyboardShortcuts({ + autoLayout, copy, - paste, cut, - duplicate, deleteSelected, - autoLayout, - groupSelection, + duplicate, + exitNodeNetwork, fitView, - screenToFlowPosition, + groupSelection, + mousePosRef: mousePos, + paste, + projectSettingsOpen, + redo, save, + screenToFlowPosition, + scissorCutRef, + scissorDraggingRef, + setInspectorOpen, + setPaletteOpen, + setProjectSettingsOpen, + setScissorTrail, + setSettingsOpen, setToolMode, - settings, + setMiniMapVisible, settingsOpen, - projectSettingsOpen, - exitNodeNetwork, showToast, - ]) - - 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 - const item = JSON.parse(raw) as PaletteItem - const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }) - addNodeFromPalette(item, position) - }, - [screenToFlowPosition, addNodeFromPalette], - ) - - const cutEdgesAtPoint = useCallback( - (event: ReactMouseEvent) => { - const hits = edgeIdsAtScreenPoint({ x: event.clientX, y: event.clientY }) - const fresh = hits.filter((id) => !scissorCutRef.current.has(id)) - if (fresh.length === 0) return - fresh.forEach((id) => scissorCutRef.current.add(id)) - const removed = removeEdgesByIds(fresh) - if (removed > 0) showToast(`已剪断 ${removed} 条连接`) - }, - [removeEdgesByIds, showToast], - ) - - const onCanvasMouseDownCapture = useCallback( - (event: ReactMouseEvent) => { - if (toolMode !== "scissors" || event.button !== 0) return - event.preventDefault() - event.stopPropagation() - scissorDraggingRef.current = true - scissorCutRef.current = new Set() - setScissorTrail([localPoint(wrapperRef.current, event)]) - cutEdgesAtPoint(event) - }, - [cutEdgesAtPoint, toolMode], - ) - - const onCanvasMouseMoveCapture = useCallback( - (event: ReactMouseEvent) => { - if (!scissorDraggingRef.current) return - event.preventDefault() - event.stopPropagation() - setScissorTrail((trail) => { - const next = [...trail, localPoint(wrapperRef.current, event)] - return next.length > 80 ? next.slice(-80) : next - }) - cutEdgesAtPoint(event) - }, - [cutEdgesAtPoint], - ) - - const onCanvasMouseUpCapture = useCallback((event: ReactMouseEvent) => { - if (!scissorDraggingRef.current) return - event.preventDefault() - event.stopPropagation() - scissorDraggingRef.current = false - scissorCutRef.current = new Set() - window.setTimeout(() => setScissorTrail([]), 120) - }, []) - - const onNodeDrag: OnNodeDrag = useCallback( - (_e, node) => { - const current = shakeRef.current.get(node.id) ?? { - lastX: node.position.x, - lastDirection: 0, - turns: 0, - disconnected: false, - } - const delta = node.position.x - current.lastX - if (Math.abs(delta) < 14) { - shakeRef.current.set(node.id, { ...current, lastX: node.position.x }) - return - } - const direction = delta > 0 ? 1 : -1 - const turns = current.lastDirection !== 0 && current.lastDirection !== direction ? current.turns + 1 : current.turns - const next = { - lastX: node.position.x, - lastDirection: direction as -1 | 1, - turns, - disconnected: current.disconnected, - } - if (!next.disconnected && turns >= 4) { - const removed = disconnectNodeConnections(node.id) - if (removed > 0) { - next.disconnected = true - showToast(`已断开 ${removed} 条连接`) - } - } - shakeRef.current.set(node.id, next) - }, - [disconnectNodeConnections, showToast], - ) - - const unlockInternals = useCallback( - (nodeId: string) => { - const count = unlockNodeInternals(nodeId) - showToast(count > 0 ? `已解锁 ${count} 个下层节点` : "这个节点没有可解锁的下层节点") - setNodeMenu(null) - }, - [showToast, unlockNodeInternals], - ) - - const diveIntoNetwork = useCallback( - (nodeId: string) => { - const count = enterNodeNetwork(nodeId) - showToast(count > 0 ? `Dive into Network: ${count} nodes` : "这个节点没有下层 Network") - setNodeMenu(null) - if (count > 0) { - window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) - } - }, - [enterNodeNetwork, fitView, showToast], - ) - - const addDopNodeFromMenu = useCallback( - (item: WorkflowNodeCatalogItem) => { - if (!nodeMenu) return - const text = localizeNodeText(item.id, { label: item.label, description: item.description }, settings.language) - addWorkflowNodeFromCatalog(item, screenToFlowPosition({ x: nodeMenu.x + 26, y: nodeMenu.y + 26 })) - showToast(`已添加 DOP 节点:${text.label}`) - setNodeMenu(null) - }, - [addWorkflowNodeFromCatalog, nodeMenu, screenToFlowPosition, settings.language, showToast], - ) - - const addPrimitiveFromMenu = useCallback( - (item: WorkflowPrimitive, itemIndex: number) => { - if (!nodeMenu) return - const text = localizeNodeText(item.id, { label: item.label, description: item.description }, settings.language) - const isInsideNetwork = useFlowStore.getState().networkStack.length > 0 - let position = screenToFlowPosition({ x: nodeMenu.x + 280, y: nodeMenu.y + 26 + itemIndex * 34 }) - - if (!isInsideNetwork) { - const count = enterNodeNetwork(nodeMenu.nodeId) - if (count > 0) { - position = { x: 780, y: 96 + itemIndex * 96 } - window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) - } else { - showToast("这个节点没有下层 Network,已在当前层添加 draft primitive") - } - } - - addPrimitiveNode(item, position, primitiveRuntimeCapability(capabilities, item.id)) - showToast(`已添加原子节点:${text.label}`) - setNodeMenu(null) - }, - [addPrimitiveNode, capabilities, enterNodeNetwork, fitView, nodeMenu, screenToFlowPosition, settings.language, showToast], - ) - - const lockInternals = useCallback( - (nodeId: string) => { - const count = lockNodeInternals(nodeId) - showToast(count > 0 ? `已收回 ${count} 个下层节点` : "没有已解锁的下层节点") - setNodeMenu(null) - }, - [lockNodeInternals, showToast], - ) + undo, + yMomentaryModeRef, + }) - const selectComponentFromMenu = useCallback( - (nodeId: string) => { - const result = selectConnectedComponent(nodeId) - showToast(`已选中组件:${result.nodeIds.length} 节点 / ${result.edgeIds.length} 连线`) - setNodeMenu(null) - if (result.nodeIds.length > 0) { - window.setTimeout(() => void fitView({ nodes: result.nodeIds.map((id) => ({ id })), padding: 0.35, duration: 260 }), 20) - } - }, - [fitView, selectConnectedComponent, showToast], - ) + const { onDragOver, onDrop } = usePaletteDrop({ addNodeFromPalette, screenToFlowPosition }) + const { onCanvasMouseDownCapture, onCanvasMouseMoveCapture, onCanvasMouseUpCapture } = useScissorCanvasHandlers({ + cutRef: scissorCutRef, + draggingRef: scissorDraggingRef, + removeEdgesByIds, + setTrail: setScissorTrail, + showToast, + toolMode, + wrapperRef, + }) + const { onNodeDrag, onNodeDragStop } = useWorkflowNodeDragHandlers({ + attachToParent, + clearHelperLines, + detachFromParent, + disconnectNodeConnections, + getInternalNode, + resizeGroupToFit, + resolveNodeCollisions, + shakeRef, + showToast, + }) + const { + addDopNodeFromMenu, + addPrimitiveFromMenu, + diveIntoNetwork, + lockInternals, + selectComponentFromMenu, + showNodeInfo, + showParameters, + unlockInternals, + } = useWorkflowNodeMenuActions({ + addPrimitiveNode, + addWorkflowNodeFromCatalog, + capabilities, + enterNodeNetwork, + fitView, + language: settings.language, + lockNodeInternals, + nodeMenu, + screenToFlowPosition, + selectConnectedComponent, + setInspectorOpen, + setNodeMenu, + showToast, + unlockNodeInternals, + }) - const onNodeDoubleClick = useCallback( - (_event: ReactMouseEvent, node: Node) => { + const onNodeDoubleClick: NodeMouseHandler = useCallback( + (_event: unknown, node: { id: string }) => { diveIntoNetwork(node.id) }, [diveIntoNetwork], ) - const onNodeContextMenu = useCallback((event: ReactMouseEvent, node: Node) => { + const onNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { event.preventDefault() event.stopPropagation() setNodeMenu({ nodeId: node.id, x: event.clientX, y: event.clientY }) }, []) - const onNodeDragStop = useCallback( - (_e: unknown, node: Node) => { - clearHelperLines() - shakeRef.current.delete(node.id) - if (node.type === "group") { - resizeGroupToFit(node.id) - return - } - const internal = getInternalNode(node.id) - const abs = internal?.internals.positionAbsolute ?? node.position - const w = node.measured?.width ?? 240 - const h = node.measured?.height ?? 96 - const cx = abs.x + w / 2 - const cy = abs.y + h / 2 - - const wf = node as WorkflowNode - const groups = useFlowStore - .getState() - .nodes.filter((n) => n.type === "group" && !n.data.collapsed && n.id !== node.id) - const targetGroup = groups.find((g) => { - const gw = (g.width as number) ?? g.measured?.width ?? 320 - const gh = (g.height as number) ?? g.measured?.height ?? 220 - return cx >= g.position.x && cx <= g.position.x + gw && cy >= g.position.y && cy <= g.position.y + gh - }) - - if (targetGroup && wf.parentId !== targetGroup.id) { - attachToParent(node.id, targetGroup.id) - } else if (!targetGroup && wf.parentId) { - detachFromParent(node.id) - } else if (targetGroup && wf.parentId === targetGroup.id) { - resizeGroupToFit(targetGroup.id) - } - resolveNodeCollisions(node.id) - }, - [clearHelperLines, getInternalNode, attachToParent, detachFromParent, resizeGroupToFit, resolveNodeCollisions], - ) - - // Prevent Cycles + Connection Limit + typed handles → isValidConnection - const isValidConnection: IsValidConnection = useCallback( - (connection) => { - const conn = { - source: connection.source, - target: connection.target, - sourceHandle: connection.sourceHandle ?? null, - targetHandle: connection.targetHandle ?? null, - } - const res = validateConnection(useFlowStore.getState().edges, conn, { - preventCycles: settings.preventCycles, - maxSourceConnections: settings.maxSourceConnections, - maxTargetConnections: settings.maxTargetConnections, - typedHandles: settings.typedHandles, - nodes: useFlowStore.getState().nodes, - }) - if (!res.ok) { - showToast(res.reason) - return false - } - return true - }, - [settings.preventCycles, settings.maxSourceConnections, settings.maxTargetConnections, settings.typedHandles, showToast], - ) - - // Confirm Delete - const onBeforeDelete: OnBeforeDelete = useCallback( - async ({ nodes: toDelNodes, edges: toDelEdges }) => { - if (!settings.confirmDelete) return { nodes: toDelNodes, edges: toDelEdges } - const count = toDelNodes.length + toDelEdges.length - if (count === 0) return false - // eslint-disable-next-line no-alert - const ok = window.confirm( - `确认删除 ${toDelNodes.length} 个节点 / ${toDelEdges.length} 条连线?`, - ) - if (!ok) return false - return { nodes: toDelNodes, edges: toDelEdges } - }, - [settings.confirmDelete], - ) + const { isValidConnection, onBeforeDelete } = useConnectionGuards({ settings, showToast }) const isDraw = toolMode === "draw" const isScissors = toolMode === "scissors" - const networkLocked = networkStack.length > 0 && nodes.some((node) => node.data.internalLocked === true) - 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, showToast], - ) - - const rejectProposal = useCallback(() => { - showToast("Agent proposal rejected") - clearProposalFocus() - setAgentDrawerOpen(false) - setAgentProposal(undefined) - }, [clearProposalFocus, showToast]) - - const presentAgentProposal = useCallback( - (proposal: AgentProposal) => { - setAgentProposal(proposal) - setAgentDrawerOpen(true) - showToast("Demand proposal ready") - }, - [showToast], - ) - - useEffect(() => { - if (!pendingAgentProposal) return - presentAgentProposal(pendingAgentProposal) - clearPendingAgentProposal() - }, [clearPendingAgentProposal, pendingAgentProposal, presentAgentProposal]) + const networkLocked = isNetworkLocked(networkStack, nodes) + const exitCurrentNetwork = useExitCurrentNetwork({ exitNodeNetwork, fitView, showToast }) + const { acceptProposal, agentProposal, focusProposalOperation, rejectProposal } = useWorkflowAgentProposal({ + clearPendingAgentProposal, + clearProposalFocus, + fitView, + focusProposalTargets, + importWorkflowProject, + pendingAgentProposal, + setAgentDrawerOpen, + showToast, + }) - 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], - ) + const onCanvasMouseMove = useCallback((event: ReactMouseEvent) => { + mousePos.current = { x: event.clientX, y: event.clientY } + }, []) - const touchProps = useMemo( - () => - settings.touchMode - ? { panOnDrag: [1, 2] as number[], selectionOnDrag: false, panOnScroll: true } - : {}, - [settings.touchMode], - ) + const toggleCollabProvider = useCallback(() => { + settings.set("collabProvider", settings.collabProvider === "off" ? "yjs" : "off") + }, [settings]) return (
@@ -719,12 +248,7 @@ function EditorCanvas() { onOpenPalette={() => setPaletteOpen(true)} onExported={showToast} collab={settings.collabProvider !== "off"} - onToggleCollab={() => - settings.set( - "collabProvider", - settings.collabProvider === "off" ? "yjs" : "off", - ) - } + onToggleCollab={toggleCollabProvider} settingsOpen={settingsOpen} onToggleSettings={() => setSettingsOpen((v) => !v)} projectSettingsOpen={projectSettingsOpen} @@ -737,328 +261,66 @@ function EditorCanvas() { onToggleNodeManagement={() => setNodeManagementOpen((v) => !v)} />
-
{ - mousePos.current = { x: e.clientX, y: e.clientY } - }} - data-zoom-bucket={zoom < 0.5 ? "low" : zoom > 1.4 ? "high" : "mid"} - > - - - {settings.showBackground ? ( - - ) : null} - {settings.showControls ? ( - - ) : null} - {settings.showMiniMap ? ( - - ) : null} - - - - - - - {isScissors ? ( - - {scissorTrail.length > 1 ? ( - <> - `${p.x},${p.y}`).join(" ")} - fill="none" - stroke="var(--background)" - strokeWidth={7} - strokeLinecap="round" - strokeLinejoin="round" - opacity={0.9} - /> - `${p.x},${p.y}`).join(" ")} - className="workflow-scissor-trail" - fill="none" - stroke="#ff7a17" - strokeWidth={2} - strokeDasharray="7 5" - strokeLinecap="round" - strokeLinejoin="round" - /> - - ) : null} - - ) : null} - - {runTraceOpen ? ( -
- -
- ) : null} - - {nodeManagementOpen ? setNodeManagementOpen(false)} /> : null} - - {networkStack.length > 0 ? ( -
- - / - obj - {networkStack.map((entry) => ( - - / - {entry.label} - - ))} - - {networkLocked ? "LOCKED" : "DRAFT"} - - Esc / Backspace -
- ) : null} - - {nodeMenu ? ( -
event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - > - -
-
- DOP Operators -
-
- {dopNodeMenuItems.map((item) => { - const text = localizeNodeText(item.id, { label: item.label, description: item.description }, settings.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 }, settings.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...
-
- ) : null} - - {projectSettingsOpen ? ( -
- -
- ) : settingsOpen ? ( -
- -
- ) : inspectorOpen ? ( -
- -
- ) : null} - - setAgentDrawerOpen(false)} - /> - - {toast ? ( -
- {toast} -
- ) : null} -
+
= { current: T } +type SetOpen = Dispatch> + +type WorkflowKeyboardShortcutOptions = { + autoLayout: (direction: "TB", engine: "elk", animated: boolean) => Promise + copy: () => void + cut: () => void + deleteSelected: () => void + duplicate: () => void + exitNodeNetwork: () => boolean + fitView: (options?: { padding?: number; duration?: number }) => unknown + groupSelection: () => void + mousePosRef: WritableRef + paste: (position?: Point) => void + projectSettingsOpen: boolean + redo: () => void + save: () => void + screenToFlowPosition: (position: Point) => Point + scissorCutRef: WritableRef> + scissorDraggingRef: WritableRef + setInspectorOpen: SetOpen + setPaletteOpen: SetOpen + setProjectSettingsOpen: SetOpen + setScissorTrail: Dispatch> + setSettingsOpen: SetOpen + setToolMode: (mode: ToolMode) => void + setMiniMapVisible: (visible: boolean) => void + settingsOpen: boolean + showToast: (message: string) => void + undo: () => void + yMomentaryModeRef: WritableRef +} + +function isEditableTarget(target: EventTarget | null) { + const element = target as HTMLElement | null + if (!element) return false + return element.tagName === "INPUT" || element.tagName === "TEXTAREA" || element.isContentEditable +} + +export function useWorkflowKeyboardShortcuts({ + autoLayout, + copy, + cut, + deleteSelected, + duplicate, + exitNodeNetwork, + fitView, + groupSelection, + mousePosRef, + paste, + projectSettingsOpen, + redo, + save, + screenToFlowPosition, + scissorCutRef, + scissorDraggingRef, + setInspectorOpen, + setPaletteOpen, + setProjectSettingsOpen, + setScissorTrail, + setSettingsOpen, + setToolMode, + setMiniMapVisible, + settingsOpen, + showToast, + undo, + yMomentaryModeRef, +}: WorkflowKeyboardShortcutOptions) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const mod = event.metaKey || event.ctrlKey + const key = event.key.toLowerCase() + + if (mod && key === "k") { + event.preventDefault() + setPaletteOpen((open) => !open) + return + } + if (isEditableTarget(event.target)) return + + if (!mod && (event.key === "Tab" || key === "b")) { + event.preventDefault() + setPaletteOpen(true) + return + } + + if (mod && key === "s") { + event.preventDefault() + save() + showToast("已保存到本地") + } else if (mod && key === "z" && !event.shiftKey) { + event.preventDefault() + undo() + } else if (mod && (key === "y" || (key === "z" && event.shiftKey))) { + event.preventDefault() + redo() + } else if (mod && key === "c") { + copy() + } else if (mod && key === "v") { + event.preventDefault() + paste(screenToFlowPosition(mousePosRef.current)) + } else if (mod && key === "x") { + cut() + } else if (mod && key === "d") { + event.preventDefault() + duplicate() + } else if (mod && key === "g") { + event.preventDefault() + groupSelection() + } else if (!mod && key === "o") { + event.preventDefault() + const next = !useSettingsStore.getState().showMiniMap + setMiniMapVisible(next) + showToast(next ? "节点缩略图已显示" : "节点缩略图已隐藏") + } else if (!mod && key === "p") { + event.preventDefault() + if (settingsOpen || projectSettingsOpen) { + setSettingsOpen(false) + setProjectSettingsOpen(false) + setInspectorOpen(true) + showToast("Parameter Interface 已显示") + return + } + setInspectorOpen((open) => { + const next = !open + showToast(next ? "Parameter Interface 已显示" : "Parameter Interface 已隐藏") + return next + }) + } else if (!mod && key === "h") { + event.preventDefault() + if (useFlowStore.getState().nodes.length === 0) return + void fitView({ padding: 0.24, duration: 220 }) + showToast("已显示全部节点") + } else if (!mod && key === "l") { + event.preventDefault() + if (useFlowStore.getState().nodes.length === 0) return + showToast("正在自动排布节点") + void autoLayout("TB", "elk", true).then(() => { + showToast("已自动排布整体节点") + window.setTimeout(() => void fitView({ padding: 0.24, duration: 260 }), 30) + }) + } else if (!mod && (event.key === "Escape" || event.key === "Backspace") && useFlowStore.getState().networkStack.length > 0) { + event.preventDefault() + if (exitNodeNetwork()) { + showToast("已返回上一层 Network") + window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) + } + } else if (!mod && key === "y") { + event.preventDefault() + if (event.repeat) return + yMomentaryModeRef.current = useFlowStore.getState().toolMode + setToolMode("scissors") + } else if (event.key === "Delete" || event.key === "Backspace") { + deleteSelected() + } + } + + const onKeyUp = (event: KeyboardEvent) => { + if (isEditableTarget(event.target)) return + if (event.metaKey || event.ctrlKey || event.key.toLowerCase() !== "y") return + if (yMomentaryModeRef.current === null) return + event.preventDefault() + const restoreMode = yMomentaryModeRef.current + yMomentaryModeRef.current = null + scissorDraggingRef.current = false + scissorCutRef.current = new Set() + setScissorTrail([]) + setToolMode(restoreMode) + } + + window.addEventListener("keydown", onKeyDown) + window.addEventListener("keyup", onKeyUp) + return () => { + window.removeEventListener("keydown", onKeyDown) + window.removeEventListener("keyup", onKeyUp) + } + }, [ + autoLayout, + copy, + cut, + deleteSelected, + duplicate, + exitNodeNetwork, + fitView, + groupSelection, + mousePosRef, + paste, + projectSettingsOpen, + redo, + save, + screenToFlowPosition, + scissorCutRef, + scissorDraggingRef, + setInspectorOpen, + setPaletteOpen, + setProjectSettingsOpen, + setScissorTrail, + setSettingsOpen, + setToolMode, + setMiniMapVisible, + settingsOpen, + showToast, + undo, + yMomentaryModeRef, + ]) +} diff --git a/frontend/components/flow/workflow-node-menu-actions.ts b/frontend/components/flow/workflow-node-menu-actions.ts new file mode 100644 index 0000000..1927349 --- /dev/null +++ b/frontend/components/flow/workflow-node-menu-actions.ts @@ -0,0 +1,143 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react" + +import { useFlowStore } from "@/lib/flow/store" +import { primitiveRuntimeCapability, 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 type { CanvasPoint } from "./workflow-canvas-geometry" + +export type NodeMenuState = { nodeId: string; x: number; y: number } + +type FitView = (options?: { padding?: number; duration?: number; nodes?: { id: string }[] }) => unknown + +export function useWorkflowNodeMenuActions(options: { + addPrimitiveNode: (item: WorkflowPrimitive, position: CanvasPoint, runtimeCapability: ReturnType) => void + addWorkflowNodeFromCatalog: (item: WorkflowNodeCatalogItem, position: CanvasPoint) => void + capabilities: WorkflowCapabilitiesResponse | null | undefined + enterNodeNetwork: (nodeId: string) => number + fitView: FitView + language: WorkflowLanguage + lockNodeInternals: (nodeId: string) => number + nodeMenu: NodeMenuState | null + screenToFlowPosition: (position: CanvasPoint) => CanvasPoint + selectConnectedComponent: (nodeId: string) => { nodeIds: string[]; edgeIds: string[] } + setInspectorOpen: Dispatch> + setNodeMenu: Dispatch> + showToast: (message: string) => void + unlockNodeInternals: (nodeId: string) => number +}) { + const { + addPrimitiveNode, + addWorkflowNodeFromCatalog, + capabilities, + enterNodeNetwork, + fitView, + language, + lockNodeInternals, + nodeMenu, + screenToFlowPosition, + selectConnectedComponent, + setInspectorOpen, + setNodeMenu, + showToast, + unlockNodeInternals, + } = options + + const unlockInternals = useCallback( + (nodeId: string) => { + const count = unlockNodeInternals(nodeId) + showToast(count > 0 ? `已解锁 ${count} 个下层节点` : "这个节点没有可解锁的下层节点") + setNodeMenu(null) + }, + [setNodeMenu, showToast, unlockNodeInternals], + ) + + const diveIntoNetwork = useCallback( + (nodeId: string) => { + const count = enterNodeNetwork(nodeId) + showToast(count > 0 ? `Dive into Network: ${count} nodes` : "这个节点没有下层 Network") + setNodeMenu(null) + if (count > 0) window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) + }, + [enterNodeNetwork, fitView, setNodeMenu, showToast], + ) + + const addDopNodeFromMenu = useCallback( + (item: WorkflowNodeCatalogItem) => { + if (!nodeMenu) return + const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language) + addWorkflowNodeFromCatalog(item, screenToFlowPosition({ x: nodeMenu.x + 26, y: nodeMenu.y + 26 })) + showToast(`已添加 DOP 节点:${text.label}`) + setNodeMenu(null) + }, + [addWorkflowNodeFromCatalog, language, nodeMenu, screenToFlowPosition, setNodeMenu, showToast], + ) + + const addPrimitiveFromMenu = useCallback( + (item: WorkflowPrimitive, itemIndex: number) => { + if (!nodeMenu) return + const text = localizeNodeText(item.id, { label: item.label, description: item.description }, language) + const isInsideNetwork = useFlowStore.getState().networkStack.length > 0 + let position = screenToFlowPosition({ x: nodeMenu.x + 280, y: nodeMenu.y + 26 + itemIndex * 34 }) + + if (!isInsideNetwork) { + const count = enterNodeNetwork(nodeMenu.nodeId) + if (count > 0) { + position = { x: 780, y: 96 + itemIndex * 96 } + window.setTimeout(() => void fitView({ padding: 0.24, duration: 180 }), 20) + } else { + showToast("这个节点没有下层 Network,已在当前层添加 draft primitive") + } + } + + addPrimitiveNode(item, position, primitiveRuntimeCapability(capabilities, item.id)) + showToast(`已添加原子节点:${text.label}`) + setNodeMenu(null) + }, + [addPrimitiveNode, capabilities, enterNodeNetwork, fitView, language, nodeMenu, screenToFlowPosition, setNodeMenu, showToast], + ) + + const lockInternals = useCallback( + (nodeId: string) => { + const count = lockNodeInternals(nodeId) + showToast(count > 0 ? `已收回 ${count} 个下层节点` : "没有已解锁的下层节点") + setNodeMenu(null) + }, + [lockNodeInternals, setNodeMenu, showToast], + ) + + const selectComponentFromMenu = useCallback( + (nodeId: string) => { + const result = selectConnectedComponent(nodeId) + showToast(`已选中组件:${result.nodeIds.length} 节点 / ${result.edgeIds.length} 连线`) + setNodeMenu(null) + if (result.nodeIds.length > 0) { + window.setTimeout(() => void fitView({ nodes: result.nodeIds.map((id) => ({ id })), padding: 0.35, duration: 260 }), 20) + } + }, + [fitView, selectConnectedComponent, setNodeMenu, showToast], + ) + + const showNodeInfo = useCallback(() => { + setNodeMenu(null) + showToast("Node information is in Parameter Interface") + }, [setNodeMenu, showToast]) + + const showParameters = useCallback(() => { + setNodeMenu(null) + setInspectorOpen(true) + showToast("Parameter Interface 已显示") + }, [setInspectorOpen, setNodeMenu, showToast]) + + return { + addDopNodeFromMenu, + addPrimitiveFromMenu, + diveIntoNetwork, + lockInternals, + selectComponentFromMenu, + showNodeInfo, + showParameters, + unlockInternals, + } +} diff --git a/frontend/lib/flow/store-layout-actions.ts b/frontend/lib/flow/store-layout-actions.ts new file mode 100644 index 0000000..511324f --- /dev/null +++ b/frontend/lib/flow/store-layout-actions.ts @@ -0,0 +1,308 @@ +import { nanoid } from "nanoid" +import type { StoreApi } from "zustand" +import { animateNodes } from "./animate" +import { COLLISION_GAP, findFreePosition, nodeRect, resolveCollisions } from "./collision" +import { getLayoutedElements } from "./layout" +import type { FlowState } from "./store" +import type { WorkflowEdge, WorkflowNode } from "./types" + +type FlowSet = StoreApi["setState"] +type FlowGet = StoreApi["getState"] + +function withoutParent(node: WorkflowNode): WorkflowNode { + const next = { ...node } + delete next.parentId + delete next.extent + return next +} + +export function createLayoutActions( + set: FlowSet, + get: FlowGet, +): Pick< + FlowState, + | "autoLayout" + | "toggleGroupCollapse" + | "groupSelection" + | "ungroupSelection" + | "attachToParent" + | "detachFromParent" + | "addChildNode" + | "insertNodeOnEdge" + | "resolveNodeCollisions" + | "resizeGroupToFit" +> { + return { + autoLayout: async (direction, engine = "elk", animated = true) => { + get().takeSnapshot() + const current = get().nodes + const { nodes } = await getLayoutedElements(current, get().edges, direction, engine) + if (!animated || typeof window === "undefined") { + set({ nodes }) + return + } + animateNodes(current, nodes, (frame) => set({ nodes: frame })) + }, + + toggleGroupCollapse: (id) => { + get().takeSnapshot() + set((state) => { + const target = state.nodes.find((n) => n.id === id) + if (!target) return {} + const collapsed = !target.data.collapsed + const expandedHeight = (target.data.expandedHeight as number) ?? (target.height as number) ?? 220 + return { + nodes: state.nodes.map((n) => { + if (n.id === id) { + return { + ...n, + data: { ...n.data, collapsed, expandedHeight }, + height: collapsed ? 56 : expandedHeight, + style: { + ...n.style, + width: (n.width as number) ?? 320, + height: collapsed ? 56 : expandedHeight, + }, + } + } + if (n.parentId === id) { + return { ...n, hidden: collapsed } + } + return n + }), + } + }) + }, + + groupSelection: () => { + const { nodes } = get() + const selected = nodes.filter((n) => n.selected && !n.parentId && n.type !== "group") + if (selected.length < 1) return + get().takeSnapshot() + + const pad = 40 + const minX = Math.min(...selected.map((n) => n.position.x)) + const minY = Math.min(...selected.map((n) => n.position.y)) + const maxX = Math.max(...selected.map((n) => n.position.x + ((n.measured?.width ?? (n.width as number)) ?? 220))) + const maxY = Math.max(...selected.map((n) => n.position.y + ((n.measured?.height ?? (n.height as number)) ?? 90))) + + const groupId = `group-${nanoid(6)}` + const width = maxX - minX + pad * 2 + const height = maxY - minY + pad * 2 + const groupNode: WorkflowNode = { + id: groupId, + type: "group", + position: { x: minX - pad, y: minY - pad }, + width, + height, + style: { width, height }, + data: { + label: "分组", + nodeType: "group", + category: "logic", + icon: "Group", + color: "var(--muted-foreground)", + }, + } + + const selectedIds = new Set(selected.map((n) => n.id)) + const updated = nodes.map((n) => { + if (!selectedIds.has(n.id)) return { ...n, selected: false } + return { + ...n, + parentId: groupId, + selected: false, + position: { x: n.position.x - (minX - pad), y: n.position.y - (minY - pad) }, + } + }) + + set({ nodes: [groupNode, ...updated] }) + }, + + ungroupSelection: () => { + const { nodes } = get() + const groups = nodes.filter((n) => n.selected && n.type === "group") + if (groups.length === 0) return + get().takeSnapshot() + const groupIds = new Set(groups.map((g) => g.id)) + const groupPos = new Map(groups.map((g) => [g.id, g.position])) + + const detached = nodes + .filter((n) => !groupIds.has(n.id)) + .map((n) => { + if (n.parentId && groupIds.has(n.parentId)) { + const gp = groupPos.get(n.parentId)! + const rest = withoutParent(n) + return { ...rest, position: { x: n.position.x + gp.x, y: n.position.y + gp.y } } + } + return n + }) + + set({ nodes: detached }) + }, + + attachToParent: (childId, parentId) => { + const { nodes } = get() + const child = nodes.find((n) => n.id === childId) + const parent = nodes.find((n) => n.id === parentId) + if (!child || !parent || child.parentId === parentId) return + get().takeSnapshot() + + const attached = nodes.map((n) => + n.id === childId + ? { + ...n, + parentId, + position: { x: n.position.x - parent.position.x, y: n.position.y - parent.position.y }, + } + : n, + ) + const parentIdx = attached.findIndex((n) => n.id === parentId) + const childIdx = attached.findIndex((n) => n.id === childId) + if (childIdx < parentIdx) { + const [childNode] = attached.splice(childIdx, 1) + const newParentIdx = attached.findIndex((n) => n.id === parentId) + attached.splice(newParentIdx + 1, 0, childNode) + } + set({ nodes: attached }) + get().resizeGroupToFit(parentId) + }, + + detachFromParent: (childId) => { + const { nodes } = get() + const child = nodes.find((n) => n.id === childId) + if (!child || !child.parentId) return + const parent = nodes.find((n) => n.id === child.parentId) + if (!parent) return + get().takeSnapshot() + set({ + nodes: nodes.map((n) => { + if (n.id !== childId) return n + const rest = withoutParent(n) + return { ...rest, position: { x: n.position.x + parent.position.x, y: n.position.y + parent.position.y } } + }), + }) + }, + + addChildNode: (parentId) => { + const { nodes, edges } = get() + const parent = nodes.find((n) => n.id === parentId) + if (!parent) return + get().takeSnapshot() + const id = nanoid(8) + const parentRect = nodeRect(parent) + const size = { width: 240, height: 96 } + const childCount = edges.filter((e) => e.source === parentId).length + const desired = { + x: parentRect.x + childCount * (size.width + COLLISION_GAP), + y: parentRect.y + parentRect.height + 96, + } + const freePos = findFreePosition(nodes, desired, size, parent.parentId) + const newNode: WorkflowNode = { + id, + type: "workflow", + position: freePos, + ...(parent.parentId ? { parentId: parent.parentId } : {}), + data: { + label: "新节点", + nodeType: "action", + category: "action", + icon: "Zap", + color: "var(--chart-1)", + status: "idle", + }, + } + const newEdge: WorkflowEdge = { + id: `e-${nanoid(6)}`, + source: parentId, + target: id, + type: "workflow", + animated: true, + } + set({ nodes: [...nodes, newNode], edges: [...edges, newEdge] }) + }, + + insertNodeOnEdge: (edgeId) => { + const { nodes, edges } = get() + const edge = edges.find((e) => e.id === edgeId) + if (!edge) return + get().takeSnapshot() + const source = nodes.find((n) => n.id === edge.source) + const target = nodes.find((n) => n.id === edge.target) + if (!source || !target) return + + const id = nanoid(8) + const newNode: WorkflowNode = { + id, + type: "workflow", + position: { + x: (source.position.x + target.position.x) / 2, + y: (source.position.y + target.position.y) / 2, + }, + data: { + label: "插入节点", + nodeType: "action", + category: "action", + icon: "Zap", + color: "var(--chart-1)", + status: "idle", + }, + } + const newEdges = edges.filter((e) => e.id !== edgeId) + newEdges.push( + { id: `e-${nanoid(6)}`, source: edge.source, target: id, type: "workflow", animated: true }, + { id: `e-${nanoid(6)}`, source: id, target: edge.target, type: "workflow", animated: true }, + ) + set({ nodes: resolveCollisions([...nodes, newNode], id), edges: newEdges }) + }, + + resolveNodeCollisions: (movedId) => { + set((state) => ({ nodes: resolveCollisions(state.nodes, movedId) })) + }, + + resizeGroupToFit: (groupId) => { + const { nodes } = get() + const group = nodes.find((n) => n.id === groupId && n.type === "group") + if (!group || group.data.collapsed) return + const children = nodes.filter((n) => n.parentId === groupId && !n.hidden) + if (children.length === 0) return + + const pad = 32 + const header = 44 + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const child of children) { + const rect = nodeRect(child) + minX = Math.min(minX, rect.x) + minY = Math.min(minY, rect.y) + maxX = Math.max(maxX, rect.x + rect.width) + maxY = Math.max(maxY, rect.y + rect.height) + } + + const shiftX = Math.max(0, pad - minX) + const shiftY = Math.max(0, header + pad - minY) + const width = Math.max((group.width as number) ?? 320, maxX + shiftX + pad) + const height = Math.max((group.height as number) ?? 220, maxY + shiftY + pad) + + set({ + nodes: nodes.map((n) => { + if (n.id === groupId) { + return { + ...n, + position: { x: n.position.x - shiftX, y: n.position.y - shiftY }, + width, + height, + style: { ...n.style, width, height }, + } + } + if (n.parentId === groupId) { + return { ...n, position: { x: n.position.x + shiftX, y: n.position.y + shiftY } } + } + return n + }), + }) + }, + } +} diff --git a/frontend/lib/flow/store-slices.ts b/frontend/lib/flow/store-slices.ts new file mode 100644 index 0000000..f15759f --- /dev/null +++ b/frontend/lib/flow/store-slices.ts @@ -0,0 +1,306 @@ +import { nanoid } from "nanoid" +import { addEdge, applyEdgeChanges, applyNodeChanges } from "@xyflow/react" +import type { StoreApi } from "zustand" +import { applyHelperLines } from "./helper-lines" +import { useSettingsStore } from "./settings-store" +import { connectedComponentEdges, findConnectedComponentForNode } from "./graph-components" +import type { FlowState } from "./store" +import { HISTORY_LIMIT, snapshot } from "./store-utils" + +type FlowSet = StoreApi["setState"] +type FlowGet = StoreApi["getState"] + +export function createWhiteboardActions( + set: FlowSet, + get: FlowGet, +): Pick { + return { + setToolMode: (mode) => set({ toolMode: mode }), + setPenColor: (color) => set({ penColor: color }), + setPenSize: (size) => set({ penSize: size }), + addStroke: (stroke) => set((state) => ({ drawings: [...state.drawings, stroke] })), + clearDrawings: () => { + get().takeSnapshot() + set({ drawings: [] }) + }, + } +} + +export function createCanvasChangeActions( + set: FlowSet, + get: FlowGet, +): Pick { + return { + onNodesChange: (changes) => { + const { changes: nextChanges, helperLines } = applyHelperLines( + changes, + get().nodes, + useSettingsStore.getState().snapToHelperLines, + ) + set({ + nodes: applyNodeChanges(nextChanges, get().nodes), + helperLines, + }) + }, + + onEdgesChange: (changes) => { + set({ edges: applyEdgeChanges(changes, get().edges) }) + }, + + onConnect: (connection) => { + get().takeSnapshot() + set({ + edges: addEdge( + { + ...connection, + type: "workflow", + animated: true, + }, + get().edges, + ), + }) + }, + + setNodes: (updater) => set((state) => ({ nodes: updater(state.nodes) })), + setSelectedIds: (ids) => set({ selectedIds: ids }), + clearHelperLines: () => set({ helperLines: { snapPosition: {} } }), + } +} + +export function createHistoryActions( + set: FlowSet, + get: FlowGet, +): Pick { + return { + takeSnapshot: () => { + set((state) => ({ + past: [...state.past, snapshot(state)].slice(-HISTORY_LIMIT), + future: [], + })) + }, + + undo: () => { + const { past } = get() + if (past.length === 0) return + const previous = past[past.length - 1] + set((state) => ({ + past: state.past.slice(0, -1), + future: [snapshot(state), ...state.future].slice(0, HISTORY_LIMIT), + nodes: previous.nodes, + edges: previous.edges, + drawings: previous.drawings ?? [], + helperLines: { snapPosition: {} }, + })) + }, + + redo: () => { + const { future } = get() + if (future.length === 0) return + const next = future[0] + set((state) => ({ + past: [...state.past, snapshot(state)].slice(-HISTORY_LIMIT), + future: state.future.slice(1), + nodes: next.nodes, + edges: next.edges, + drawings: next.drawings ?? [], + helperLines: { snapPosition: {} }, + })) + }, + + canUndo: () => get().past.length > 0, + canRedo: () => get().future.length > 0, + } +} + +export function createSelectionActions( + set: FlowSet, + get: FlowGet, +): Pick< + FlowState, + | "deleteSelected" + | "disconnectSelectedConnections" + | "disconnectNodeConnections" + | "removeEdgesByIds" + | "selectConnectedComponent" + | "duplicateSelected" + | "copy" + | "cut" + | "paste" +> { + return { + deleteSelected: () => { + const { nodes, edges } = get() + const selectedNodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) + const selectedEdgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)) + if (selectedNodeIds.size === 0 && selectedEdgeIds.size === 0) return + get().takeSnapshot() + set({ + nodes: nodes.filter((n) => !selectedNodeIds.has(n.id)), + edges: edges.filter( + (e) => !selectedEdgeIds.has(e.id) && !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target), + ), + }) + }, + + disconnectSelectedConnections: () => { + const { nodes, edges } = get() + const selectedNodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) + const selectedEdgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)) + if (selectedNodeIds.size === 0 && selectedEdgeIds.size === 0) return 0 + const nextEdges = edges.filter( + (e) => !selectedEdgeIds.has(e.id) && !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target), + ) + const removed = edges.length - nextEdges.length + if (removed === 0) return 0 + get().takeSnapshot() + set({ edges: nextEdges }) + return removed + }, + + disconnectNodeConnections: (nodeId) => { + const { edges } = get() + const nextEdges = edges.filter((e) => e.source !== nodeId && e.target !== nodeId) + const removed = edges.length - nextEdges.length + if (removed === 0) return 0 + get().takeSnapshot() + set({ edges: nextEdges }) + return removed + }, + + removeEdgesByIds: (edgeIds) => { + const ids = new Set(edgeIds) + if (ids.size === 0) return 0 + const { edges } = get() + const nextEdges = edges.filter((e) => !ids.has(e.id)) + const removed = edges.length - nextEdges.length + if (removed === 0) return 0 + get().takeSnapshot() + set({ edges: nextEdges }) + return removed + }, + + selectConnectedComponent: (nodeId) => { + const { nodes, edges } = get() + const nodeIds = findConnectedComponentForNode(nodeId, nodes, edges) + const edgeIds = connectedComponentEdges(nodeIds, edges) + const selectedNodes = new Set(nodeIds) + const selectedEdges = new Set(edgeIds) + set({ + nodes: nodes.map((node) => ({ ...node, selected: selectedNodes.has(node.id) })), + edges: edges.map((edge) => ({ ...edge, selected: selectedEdges.has(edge.id) })), + }) + return { nodeIds, edgeIds } + }, + + duplicateSelected: () => { + const { nodes } = get() + const selected = nodes.filter((n) => n.selected) + if (selected.length === 0) return + get().takeSnapshot() + const clones = selected.map((n) => ({ + ...n, + id: nanoid(8), + selected: true, + position: { x: n.position.x + 40, y: n.position.y + 40 }, + data: JSON.parse(JSON.stringify(n.data)), + })) + set({ + nodes: [...nodes.map((n) => ({ ...n, selected: false })), ...clones], + }) + }, + + copy: () => { + const { nodes, edges } = get() + const selectedNodes = nodes.filter((n) => n.selected) + if (selectedNodes.length === 0) return + const ids = new Set(selectedNodes.map((n) => n.id)) + const internalEdges = edges.filter((e) => ids.has(e.source) && ids.has(e.target)) + set({ + clipboard: { + nodes: JSON.parse(JSON.stringify(selectedNodes)), + edges: JSON.parse(JSON.stringify(internalEdges)), + }, + }) + }, + + cut: () => { + get().copy() + get().deleteSelected() + }, + + paste: (position) => { + const { clipboard, nodes } = get() + if (!clipboard || clipboard.nodes.length === 0) return + get().takeSnapshot() + + const idMap = new Map() + const minX = Math.min(...clipboard.nodes.map((n) => n.position.x)) + const minY = Math.min(...clipboard.nodes.map((n) => n.position.y)) + const offsetX = position ? position.x - minX : 48 + const offsetY = position ? position.y - minY : 48 + + const newNodes = clipboard.nodes.map((n) => { + const newId = nanoid(8) + idMap.set(n.id, newId) + return { + ...n, + id: newId, + selected: true, + position: { x: n.position.x + offsetX, y: n.position.y + offsetY }, + data: JSON.parse(JSON.stringify(n.data)), + } + }) + + const newEdges = clipboard.edges.map((e) => ({ + ...e, + id: `e-${nanoid(6)}`, + source: idMap.get(e.source) ?? e.source, + target: idMap.get(e.target) ?? e.target, + selected: false, + })) + + set((state) => ({ + nodes: [...nodes.map((n) => ({ ...n, selected: false })), ...newNodes], + edges: [...state.edges, ...newEdges], + })) + }, + } +} + +export function createEdgeActions( + set: FlowSet, + get: FlowGet, +): Pick { + return { + updateEdgeWaypoints: (edgeId, waypoints) => { + set((state) => ({ + edges: state.edges.map((e) => + e.id === edgeId ? { ...e, type: "editable", data: { ...e.data, waypoints } } : e, + ), + })) + }, + + updateEdgeData: (edgeId, data) => { + set((state) => ({ + edges: state.edges.map((e) => (e.id === edgeId ? { ...e, data: { ...e.data, ...data } } : e)), + })) + }, + + updateEdgeType: (edgeId, type) => { + get().takeSnapshot() + set((state) => ({ + edges: state.edges.map((e) => + e.id === edgeId + ? { ...e, type, data: { ...e.data, ...(type === "editable" ? {} : { waypoints: undefined }) } } + : e, + ), + })) + }, + + toggleEdgeAnimated: (edgeId) => { + set((state) => ({ + edges: state.edges.map((e) => (e.id === edgeId ? { ...e, animated: !e.animated } : e)), + })) + }, + } +} diff --git a/frontend/lib/flow/store-utils.ts b/frontend/lib/flow/store-utils.ts new file mode 100644 index 0000000..a2ff500 --- /dev/null +++ b/frontend/lib/flow/store-utils.ts @@ -0,0 +1,15 @@ +import type { FlowSnapshot, FreehandStroke, WorkflowEdge, WorkflowNode } from "./types" + +export const HISTORY_LIMIT = 100 + +export function snapshot(state: { + nodes: WorkflowNode[] + edges: WorkflowEdge[] + drawings: FreehandStroke[] +}): FlowSnapshot { + return { + nodes: JSON.parse(JSON.stringify(state.nodes)), + edges: JSON.parse(JSON.stringify(state.edges)), + drawings: JSON.parse(JSON.stringify(state.drawings)), + } +} diff --git a/frontend/lib/flow/store.ts b/frontend/lib/flow/store.ts index 11c6e24..3a4a060 100644 --- a/frontend/lib/flow/store.ts +++ b/frontend/lib/flow/store.ts @@ -3,9 +3,6 @@ import { create } from "zustand" import { nanoid } from "nanoid" import { - addEdge, - applyEdgeChanges, - applyNodeChanges, type Connection, type EdgeChange, type NodeChange, @@ -23,13 +20,19 @@ import type { GeneratedWorkflowSpec, ParameterInterface, } from "./types" -import { applyHelperLines, type HelperLines } from "./helper-lines" -import { useSettingsStore } from "./settings-store" -import { resolveCollisions, findFreePosition, nodeRect, COLLISION_GAP } from "./collision" -import { connectedComponentEdges, findConnectedComponentForNode } from "./graph-components" -import { getLayoutedElements, type LayoutDirection, type LayoutEngine } from "./layout" -import { animateNodes } from "./animate" +import type { HelperLines } from "./helper-lines" +import { resolveCollisions, findFreePosition, nodeRect } from "./collision" +import type { LayoutDirection, LayoutEngine } from "./layout" import { NODE_PALETTE } from "./palette" +import { createLayoutActions } from "./store-layout-actions" +import { + createCanvasChangeActions, + createEdgeActions, + createHistoryActions, + createSelectionActions, + createWhiteboardActions, +} from "./store-slices" +import { snapshot } from "./store-utils" import { COLLECTION_WORKFLOW_PROJECT } from "../workflow/collection-pipeline" import type { WorkflowProject } from "../workflow/schema" import { parseWorkflowProject, type AdapterBinding, type WorkflowProfile, type WorkflowProjectNode } from "../workflow/schema" @@ -53,12 +56,11 @@ import type { export type { GeneratedWorkflowSpec } from "./types" -const HISTORY_LIMIT = 100 const STORAGE_KEY = "workflow-editor-state" const initialWorkflowProject = COLLECTION_WORKFLOW_PROJECT const initialWorkflowFlow = workflowProjectToReactFlow(initialWorkflowProject) -type FlowState = { +export type FlowState = { workflowProject: WorkflowProject nodes: WorkflowNode[] edges: WorkflowEdge[] @@ -163,14 +165,6 @@ type FlowState = { applyGeneratedWorkflow: (spec: GeneratedWorkflowSpec) => void } -function snapshot(state: Pick): FlowSnapshot { - return { - nodes: JSON.parse(JSON.stringify(state.nodes)), - edges: JSON.parse(JSON.stringify(state.edges)), - drawings: JSON.parse(JSON.stringify(state.drawings)), - } -} - function uniqueWorkflowNodeId(prefix: string, nodes: WorkflowProject["nodes"]): string { const ids = new Set(nodes.map((node) => node.id)) let candidate = prefix @@ -598,82 +592,9 @@ export const useFlowStore = create((set, get) => ({ penColor: "var(--chart-1)", penSize: 4, - setToolMode: (mode) => set({ toolMode: mode }), - setPenColor: (color) => set({ penColor: color }), - setPenSize: (size) => set({ penSize: size }), - addStroke: (stroke) => set((state) => ({ drawings: [...state.drawings, stroke] })), - clearDrawings: () => { - get().takeSnapshot() - set({ drawings: [] }) - }, - - onNodesChange: (changes) => { - const { changes: nextChanges, helperLines } = applyHelperLines( - changes, - get().nodes, - useSettingsStore.getState().snapToHelperLines, - ) - set({ - nodes: applyNodeChanges(nextChanges, get().nodes), - helperLines, - }) - }, - - onEdgesChange: (changes) => { - set({ edges: applyEdgeChanges(changes, get().edges) }) - }, - - onConnect: (connection) => { - get().takeSnapshot() - set({ - edges: addEdge( - { - ...connection, - type: "workflow", - animated: true, - }, - get().edges, - ), - }) - }, - - takeSnapshot: () => { - set((state) => ({ - past: [...state.past, snapshot(state)].slice(-HISTORY_LIMIT), - future: [], - })) - }, - - undo: () => { - const { past } = get() - if (past.length === 0) return - const previous = past[past.length - 1] - set((state) => ({ - past: state.past.slice(0, -1), - future: [snapshot(state), ...state.future].slice(0, HISTORY_LIMIT), - nodes: previous.nodes, - edges: previous.edges, - drawings: previous.drawings ?? [], - helperLines: { snapPosition: {} }, - })) - }, - - redo: () => { - const { future } = get() - if (future.length === 0) return - const next = future[0] - set((state) => ({ - past: [...state.past, snapshot(state)].slice(-HISTORY_LIMIT), - future: state.future.slice(1), - nodes: next.nodes, - edges: next.edges, - drawings: next.drawings ?? [], - helperLines: { snapPosition: {} }, - })) - }, - - canUndo: () => get().past.length > 0, - canRedo: () => get().future.length > 0, + ...createWhiteboardActions(set, get), + ...createCanvasChangeActions(set, get), + ...createHistoryActions(set, get), addNodeFromPalette: (item, position) => { get().takeSnapshot() @@ -854,405 +775,10 @@ export const useFlowStore = create((set, get) => ({ })) }, - deleteSelected: () => { - const { nodes, edges } = get() - const selectedNodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) - const selectedEdgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)) - if (selectedNodeIds.size === 0 && selectedEdgeIds.size === 0) return - get().takeSnapshot() - set({ - nodes: nodes.filter((n) => !selectedNodeIds.has(n.id)), - edges: edges.filter( - (e) => !selectedEdgeIds.has(e.id) && !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target), - ), - }) - }, - - disconnectSelectedConnections: () => { - const { nodes, edges } = get() - const selectedNodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) - const selectedEdgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)) - if (selectedNodeIds.size === 0 && selectedEdgeIds.size === 0) return 0 - const nextEdges = edges.filter( - (e) => !selectedEdgeIds.has(e.id) && !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target), - ) - const removed = edges.length - nextEdges.length - if (removed === 0) return 0 - get().takeSnapshot() - set({ edges: nextEdges }) - return removed - }, - - disconnectNodeConnections: (nodeId) => { - const { edges } = get() - const nextEdges = edges.filter((e) => e.source !== nodeId && e.target !== nodeId) - const removed = edges.length - nextEdges.length - if (removed === 0) return 0 - get().takeSnapshot() - set({ edges: nextEdges }) - return removed - }, - - removeEdgesByIds: (edgeIds) => { - const ids = new Set(edgeIds) - if (ids.size === 0) return 0 - const { edges } = get() - const nextEdges = edges.filter((e) => !ids.has(e.id)) - const removed = edges.length - nextEdges.length - if (removed === 0) return 0 - get().takeSnapshot() - set({ edges: nextEdges }) - return removed - }, - - selectConnectedComponent: (nodeId) => { - const { nodes, edges } = get() - const nodeIds = findConnectedComponentForNode(nodeId, nodes, edges) - const edgeIds = connectedComponentEdges(nodeIds, edges) - const selectedNodes = new Set(nodeIds) - const selectedEdges = new Set(edgeIds) - set({ - nodes: nodes.map((node) => ({ ...node, selected: selectedNodes.has(node.id) })), - edges: edges.map((edge) => ({ ...edge, selected: selectedEdges.has(edge.id) })), - }) - return { nodeIds, edgeIds } - }, - - duplicateSelected: () => { - const { nodes } = get() - const selected = nodes.filter((n) => n.selected) - if (selected.length === 0) return - get().takeSnapshot() - const idMap = new Map() - const clones = selected.map((n) => { - const newId = nanoid(8) - idMap.set(n.id, newId) - return { - ...n, - id: newId, - selected: true, - position: { x: n.position.x + 40, y: n.position.y + 40 }, - data: JSON.parse(JSON.stringify(n.data)), - } - }) - set({ - nodes: [...nodes.map((n) => ({ ...n, selected: false })), ...clones], - }) - }, - - copy: () => { - const { nodes, edges } = get() - const selectedNodes = nodes.filter((n) => n.selected) - if (selectedNodes.length === 0) return - const ids = new Set(selectedNodes.map((n) => n.id)) - const internalEdges = edges.filter((e) => ids.has(e.source) && ids.has(e.target)) - set({ - clipboard: { - nodes: JSON.parse(JSON.stringify(selectedNodes)), - edges: JSON.parse(JSON.stringify(internalEdges)), - }, - }) - }, - - cut: () => { - get().copy() - get().deleteSelected() - }, - - paste: (position) => { - const { clipboard, nodes } = get() - if (!clipboard || clipboard.nodes.length === 0) return - get().takeSnapshot() - - const idMap = new Map() - // anchor offset - const minX = Math.min(...clipboard.nodes.map((n) => n.position.x)) - const minY = Math.min(...clipboard.nodes.map((n) => n.position.y)) - const offsetX = position ? position.x - minX : 48 - const offsetY = position ? position.y - minY : 48 - - const newNodes = clipboard.nodes.map((n) => { - const newId = nanoid(8) - idMap.set(n.id, newId) - return { - ...n, - id: newId, - selected: true, - position: { x: n.position.x + offsetX, y: n.position.y + offsetY }, - data: JSON.parse(JSON.stringify(n.data)), - } - }) - - const newEdges = clipboard.edges.map((e) => ({ - ...e, - id: `e-${nanoid(6)}`, - source: idMap.get(e.source) ?? e.source, - target: idMap.get(e.target) ?? e.target, - selected: false, - })) - - set((state) => ({ - nodes: [...nodes.map((n) => ({ ...n, selected: false })), ...newNodes], - edges: [...state.edges, ...newEdges], - })) - }, - - autoLayout: async (direction, engine = "elk", animated = true) => { - get().takeSnapshot() - const current = get().nodes - const { nodes } = await getLayoutedElements(current, get().edges, direction, engine) - if (!animated || typeof window === "undefined") { - set({ nodes }) - return - } - animateNodes(current, nodes, (frame) => set({ nodes: frame })) - }, - - toggleGroupCollapse: (id) => { - get().takeSnapshot() - set((state) => { - const target = state.nodes.find((n) => n.id === id) - if (!target) return {} - const collapsed = !target.data.collapsed - const expandedHeight = (target.data.expandedHeight as number) ?? (target.height as number) ?? 220 - return { - nodes: state.nodes.map((n) => { - if (n.id === id) { - return { - ...n, - data: { ...n.data, collapsed, expandedHeight }, - height: collapsed ? 56 : expandedHeight, - style: { - ...n.style, - width: (n.width as number) ?? 320, - height: collapsed ? 56 : expandedHeight, - }, - } - } - if (n.parentId === id) { - return { ...n, hidden: collapsed } - } - return n - }), - } - }) - }, - - groupSelection: () => { - const { nodes } = get() - const selected = nodes.filter((n) => n.selected && !n.parentId && n.type !== "group") - if (selected.length < 1) return - get().takeSnapshot() - - const PAD = 40 - const minX = Math.min(...selected.map((n) => n.position.x)) - const minY = Math.min(...selected.map((n) => n.position.y)) - const maxX = Math.max(...selected.map((n) => n.position.x + ((n.measured?.width ?? (n.width as number)) ?? 220))) - const maxY = Math.max(...selected.map((n) => n.position.y + ((n.measured?.height ?? (n.height as number)) ?? 90))) - - const groupId = `group-${nanoid(6)}` - const width = maxX - minX + PAD * 2 - const height = maxY - minY + PAD * 2 - const groupNode: WorkflowNode = { - id: groupId, - type: "group", - position: { x: minX - PAD, y: minY - PAD }, - width, - height, - style: { width, height }, - data: { - label: "分组", - nodeType: "group", - category: "logic", - icon: "Group", - color: "var(--muted-foreground)", - }, - } - - const selectedIds = new Set(selected.map((n) => n.id)) - const updated = nodes.map((n) => { - if (!selectedIds.has(n.id)) return { ...n, selected: false } - return { - ...n, - parentId: groupId, - selected: false, - position: { x: n.position.x - (minX - PAD), y: n.position.y - (minY - PAD) }, - } - }) - - set({ nodes: [groupNode, ...updated] }) - }, - - ungroupSelection: () => { - const { nodes } = get() - const groups = nodes.filter((n) => n.selected && n.type === "group") - if (groups.length === 0) return - get().takeSnapshot() - const groupIds = new Set(groups.map((g) => g.id)) - const groupPos = new Map(groups.map((g) => [g.id, g.position])) - - const detached = nodes - .filter((n) => !groupIds.has(n.id)) - .map((n) => { - if (n.parentId && groupIds.has(n.parentId)) { - const gp = groupPos.get(n.parentId)! - const { parentId, extent, ...rest } = n - return { ...rest, position: { x: n.position.x + gp.x, y: n.position.y + gp.y } } - } - return n - }) - - set({ nodes: detached }) - }, - - attachToParent: (childId, parentId) => { - const { nodes } = get() - const child = nodes.find((n) => n.id === childId) - const parent = nodes.find((n) => n.id === parentId) - if (!child || !parent || child.parentId === parentId) return - get().takeSnapshot() - - const attached = nodes.map((n) => - n.id === childId - ? { - ...n, - parentId, - position: { x: n.position.x - parent.position.x, y: n.position.y - parent.position.y }, - } - : n, - ) - // React Flow 要求 parent 必须出现在 child 之前,否则子节点渲染异常("消失") - const parentIdx = attached.findIndex((n) => n.id === parentId) - const childIdx = attached.findIndex((n) => n.id === childId) - if (childIdx < parentIdx) { - const [childNode] = attached.splice(childIdx, 1) - const newParentIdx = attached.findIndex((n) => n.id === parentId) - attached.splice(newParentIdx + 1, 0, childNode) - } - set({ nodes: attached }) - get().resizeGroupToFit(parentId) - }, - - detachFromParent: (childId) => { - const { nodes } = get() - const child = nodes.find((n) => n.id === childId) - if (!child || !child.parentId) return - const parent = nodes.find((n) => n.id === child.parentId) - if (!parent) return - get().takeSnapshot() - set({ - nodes: nodes.map((n) => { - if (n.id !== childId) return n - const { parentId, extent, ...rest } = n - return { ...rest, position: { x: n.position.x + parent.position.x, y: n.position.y + parent.position.y } } - }), - }) - }, - - updateEdgeWaypoints: (edgeId, waypoints) => { - set((state) => ({ - edges: state.edges.map((e) => - e.id === edgeId ? { ...e, type: "editable", data: { ...e.data, waypoints } } : e, - ), - })) - }, - - updateEdgeData: (edgeId, data) => { - set((state) => ({ - edges: state.edges.map((e) => (e.id === edgeId ? { ...e, data: { ...e.data, ...data } } : e)), - })) - }, - - updateEdgeType: (edgeId, type) => { - get().takeSnapshot() - set((state) => ({ - edges: state.edges.map((e) => - e.id === edgeId - ? { ...e, type, data: { ...e.data, ...(type === "editable" ? {} : { waypoints: undefined }) } } - : e, - ), - })) - }, - - toggleEdgeAnimated: (edgeId) => { - set((state) => ({ - edges: state.edges.map((e) => (e.id === edgeId ? { ...e, animated: !e.animated } : e)), - })) - }, + ...createSelectionActions(set, get), + ...createLayoutActions(set, get), - addChildNode: (parentId) => { - const { nodes, edges } = get() - const parent = nodes.find((n) => n.id === parentId) - if (!parent) return - get().takeSnapshot() - const id = nanoid(8) - const parentRect = nodeRect(parent) - const size = { width: 240, height: 96 } - // 端口在上下两侧 → 子节点放在父节点下方,同层不重叠(不做全图重排) - const childCount = edges.filter((e) => e.source === parentId).length - const desired = { - x: parentRect.x + childCount * (size.width + COLLISION_GAP), - y: parentRect.y + parentRect.height + 96, - } - const freePos = findFreePosition(nodes, desired, size, parent.parentId) - const newNode: WorkflowNode = { - id, - type: "workflow", - position: freePos, - ...(parent.parentId ? { parentId: parent.parentId } : {}), - data: { - label: "新节点", - nodeType: "action", - category: "action", - icon: "Zap", - color: "var(--chart-1)", - status: "idle", - }, - } - const newEdge: WorkflowEdge = { - id: `e-${nanoid(6)}`, - source: parentId, - target: id, - type: "workflow", - animated: true, - } - set({ nodes: [...nodes, newNode], edges: [...edges, newEdge] }) - }, - - insertNodeOnEdge: (edgeId) => { - const { nodes, edges } = get() - const edge = edges.find((e) => e.id === edgeId) - if (!edge) return - get().takeSnapshot() - const source = nodes.find((n) => n.id === edge.source) - const target = nodes.find((n) => n.id === edge.target) - if (!source || !target) return - - const id = nanoid(8) - const newNode: WorkflowNode = { - id, - type: "workflow", - position: { - x: (source.position.x + target.position.x) / 2, - y: (source.position.y + target.position.y) / 2, - }, - data: { - label: "插入节点", - nodeType: "action", - category: "action", - icon: "Zap", - color: "var(--chart-1)", - status: "idle", - }, - } - const newEdges = edges.filter((e) => e.id !== edgeId) - newEdges.push( - { id: `e-${nanoid(6)}`, source: edge.source, target: id, type: "workflow", animated: true }, - { id: `e-${nanoid(6)}`, source: id, target: edge.target, type: "workflow", animated: true }, - ) - // 插入点保持原位,把周围节点推开,避免全图重排 - set({ nodes: resolveCollisions([...nodes, newNode], id), edges: newEdges }) - }, + ...createEdgeActions(set, get), enterNodeNetwork: (nodeId) => { const { workflowProject, nodes, edges, drawings, networkStack } = get() @@ -1453,60 +979,6 @@ export const useFlowStore = create((set, get) => ({ return internalIds.size }, - resolveNodeCollisions: (movedId) => { - set((state) => ({ nodes: resolveCollisions(state.nodes, movedId) })) - }, - - resizeGroupToFit: (groupId) => { - const { nodes } = get() - const group = nodes.find((n) => n.id === groupId && n.type === "group") - if (!group || group.data.collapsed) return - const children = nodes.filter((n) => n.parentId === groupId && !n.hidden) - if (children.length === 0) return - - const PAD = 32 - const HEADER = 44 - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - for (const c of children) { - const r = nodeRect(c) - minX = Math.min(minX, r.x) - minY = Math.min(minY, r.y) - maxX = Math.max(maxX, r.x + r.width) - maxY = Math.max(maxY, r.y + r.height) - } - - // 子节点相对坐标可能为负(拖到分组左/上侧)→ 平移分组原点并修正所有子节点 - const shiftX = Math.max(0, PAD - minX) - const shiftY = Math.max(0, HEADER + PAD - minY) - const width = Math.max((group.width as number) ?? 320, maxX + shiftX + PAD) - const height = Math.max((group.height as number) ?? 220, maxY + shiftY + PAD) - - set({ - nodes: nodes.map((n) => { - if (n.id === groupId) { - return { - ...n, - position: { x: n.position.x - shiftX, y: n.position.y - shiftY }, - width, - height, - style: { ...n.style, width, height }, - } - } - if (n.parentId === groupId) { - return { ...n, position: { x: n.position.x + shiftX, y: n.position.y + shiftY } } - } - return n - }), - }) - }, - - setNodes: (updater) => set((state) => ({ nodes: updater(state.nodes) })), - setSelectedIds: (ids) => set({ selectedIds: ids }), - clearHelperLines: () => set({ helperLines: { snapPosition: {} } }), - save: () => { const { nodes, edges, drawings } = get() if (typeof window === "undefined") return diff --git a/openspec/changes/runtime-conformance-next-granularity/proposal.md b/openspec/changes/runtime-conformance-next-granularity/proposal.md new file mode 100644 index 0000000..acba22f --- /dev/null +++ b/openspec/changes/runtime-conformance-next-granularity/proposal.md @@ -0,0 +1,48 @@ +## Why + +The first workflow runtime conformance slice proves registry declaration, +fixture execution, and observed `/events` snapshot transcripts for the current +backend path. It intentionally leaves config-blocked evidence, SSE parity, +ODP/Redis mirroring, full node I/O contracts, and real webhook delivery outside +that first slice. + +Those items are coupled, but they are not one deliverable. If they are tracked +as a single "runtime support" task, the project can again look supported from +Canvas labels while runtime truth remains unproven. The next conformance change +needs a finer acceptance ladder. + +## What Changes + +- Define the next workflow runtime conformance granularity as five separately + verifiable layers: + 1. config-blocked evidence + 2. SSE/events-stream parity + 3. ODP/Redis event mirroring + 4. real node I/O contracts + 5. webhook real delivery +- Require the implementation order to start with stable block reason taxonomy, + then config fixtures, then event transport parity, then node I/O contracts, + then webhook delivery. +- Keep the first conformance slice truthful: it remains partial until later + fixture groups pass their own evidence gates. +- Align this planning layer with `real-node-io-webhook-runtime` without claiming + that change is complete. + +## Capabilities + +### New Capabilities + +- `runtime-conformance-next-granularity`: Acceptance ladder for completing + workflow runtime support after the first backend conformance slice. + +### Modified Capabilities + +- `workflow-runtime-conformance`: Clarifies that partial runtime passports can + only become complete after all next-granularity layers are evidenced. + +## Impact + +- OpenSpec acceptance criteria for the next runtime conformance milestones. +- A checklist that keeps config, stream transport, ODP/Redis, node I/O, and + webhook delivery independently testable. +- No runtime behavior changes in this planning-only change. diff --git a/openspec/changes/runtime-conformance-next-granularity/specs/runtime-conformance-next-granularity/spec.md b/openspec/changes/runtime-conformance-next-granularity/specs/runtime-conformance-next-granularity/spec.md new file mode 100644 index 0000000..369b35e --- /dev/null +++ b/openspec/changes/runtime-conformance-next-granularity/specs/runtime-conformance-next-granularity/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: Next runtime conformance is layered +The project SHALL complete workflow runtime conformance through separately +verifiable layers rather than a single broad runtime-support claim. + +#### Scenario: Layers are ordered +- **WHEN** planning work after the first backend conformance slice +- **THEN** the acceptance order is block reason taxonomy, config-blocked fixtures, SSE parity with `/events` snapshot, ODP/Redis event mirror, real node I/O contracts, and webhook real delivery. + +#### Scenario: First slice remains partial +- **WHEN** only the current `/events` snapshot conformance slice has passed +- **THEN** generated runtime passports remain `partial` and SHALL NOT claim config-blocked, SSE, ODP/Redis, real node I/O, or real webhook delivery certification. + +### Requirement: Config absence is blocked, not failed +The backend SHALL expose missing runtime configuration as stable blocked +evidence instead of failed runs or silent skips. + +#### Scenario: Missing webhook URL blocks delivery +- **WHEN** `workflow.notifier.webhook.send` is selected without a configured webhook URL +- **THEN** the run remains `valid=true`, projection status is `blocked`, and the event transcript includes a stable block reason code for missing webhook configuration. + +#### Scenario: Missing source credential blocks fetch +- **WHEN** a source/fetch binding requires a credential that is absent +- **THEN** the run remains `valid=true`, projection status is `blocked`, and the event transcript includes a stable block reason code for missing source credential. + +#### Scenario: Missing runtime resource blocks execution +- **WHEN** a runtime-backed node requires a resource that cannot be resolved +- **THEN** the run remains `valid=true`, projection status is `blocked`, and the event transcript includes a stable block reason code for missing runtime resource. + +### Requirement: SSE stream matches snapshot semantics +The workflow event stream SHALL carry the same stable event facts as the +existing `/api/v1/workflows/runs/{runId}/events` snapshot API. + +#### Scenario: Snapshot and stream share a matcher +- **WHEN** a conformance run is observed through both `/events` and `/events/stream` +- **THEN** the same expected transcript matcher can validate node id, event type, binding id, block reason code, message substring, event details subset, and block reason details subset from both sources. + +#### Scenario: Volatile stream fields are ignored +- **WHEN** stream event ids, timestamps, sequence numbers, or transport framing differ from snapshot output +- **THEN** the matcher ignores those volatile fields unless a fixture explicitly asserts ordering. + +### Requirement: ODP and Redis mirror runtime event evidence +Workflow-run event evidence SHALL be available from the ODP/Redis event stream +after the event transport layer is enabled. + +#### Scenario: Redis stream mirrors stable event facts +- **WHEN** a canonical conformance run emits workflow events +- **THEN** Redis/ODP stream output contains the same stable event facts required by the expected transcript. + +#### Scenario: Stream mirror does not replace public API evidence +- **WHEN** ODP/Redis stream evidence is generated +- **THEN** `/events` snapshot evidence remains available and both sources can be compared against the same expected transcript. + +### Requirement: Supported runtime bindings declare real node I/O +Every binding marked runnable SHALL have an explicit runtime I/O contract. + +#### Scenario: Runnable binding has complete contract +- **WHEN** a runtime binding is marked runnable +- **THEN** it declares input shape, output shape, permission gate, config gate, event shape, and fixture coverage. + +#### Scenario: Incomplete binding is not runnable +- **WHEN** a binding lacks input shape, output shape, permission gate, config gate, event shape, or fixture coverage +- **THEN** Canvas and backend capability surfaces expose it as blocked, preview-only, or design-only rather than runnable. + +#### Scenario: Contract truth is projected without resource internals +- **WHEN** Canvas capability/status surfaces expose a runtime binding +- **THEN** they include the stable node I/O contract summary and do not expose secret values, service URLs, or runtime resource internals as user-entered fields. + +### Requirement: Webhook delivery becomes real only when all gates pass +`workflow.notifier.webhook.send` SHALL perform real HTTP delivery only when its +permission, configuration, and resource projection gates are satisfied. + +#### Scenario: Webhook sends real HTTP request +- **WHEN** send permission is granted, webhook URL is configured, and EvidenceBatch/resource projection is available +- **THEN** the node performs deterministic HTTP delivery and emits stable delivery evidence in the run transcript. + +#### Scenario: Webhook preconditions block explicitly +- **WHEN** send permission, webhook URL, or EvidenceBatch/resource projection is missing +- **THEN** the node does not send HTTP and emits stable blocked evidence for the unmet precondition. diff --git a/openspec/changes/runtime-conformance-next-granularity/tasks.md b/openspec/changes/runtime-conformance-next-granularity/tasks.md new file mode 100644 index 0000000..db7c4de --- /dev/null +++ b/openspec/changes/runtime-conformance-next-granularity/tasks.md @@ -0,0 +1,49 @@ +## 1. Alignment Contract + +- [x] 1.1 Define the five next-granularity conformance layers. +- [x] 1.2 Preserve the first conformance slice as partial until all later layers have evidence. +- [x] 1.3 Align with `real-node-io-webhook-runtime` without marking its runtime work complete. + +## 2. Block Reason Taxonomy + +- [x] 2.1 Define stable block reason codes for missing config, missing source credential, missing runtime resource, missing permission, and missing runtime binding. +- [x] 2.2 Document which block reason fields are stable matcher inputs and which fields are volatile diagnostics. +- [x] 2.3 Add matcher support for config/resource block reason details without depending on timestamps, generated ids, or environment-specific paths. + +## 3. Config-Blocked Fixtures + +- [x] 3.1 Add a missing `webhook_url` fixture for `workflow.notifier.webhook.send`. +- [x] 3.2 Add a missing source credential fixture for a source/fetch binding. +- [x] 3.3 Add a missing runtime resource fixture for a runtime-backed node. +- [x] 3.4 Verify each fixture remains `valid=true`, emits projection status `blocked`, and records stable `block_reason.code`. + +## 4. SSE Events Stream + +- [x] 4.1 Add `/api/v1/workflows/runs/{runId}/events/stream` smoke coverage for a canonical conformance run. +- [x] 4.2 Reuse the same expected transcript matcher for `/events` snapshot and `/events/stream` output. +- [x] 4.3 Document the volatile fields that differ between snapshot and stream events. + +## 5. ODP/Redis Event Mirror + +- [x] 5.1 Identify or add the workflow-run event publisher path for ODP/Redis. +- [x] 5.2 Add a fixture that reads the same stable event facts from Redis/ODP stream output. +- [x] 5.3 Verify Redis/ODP stream evidence aligns with the canonical expected transcript. + +## 6. Real Node I/O Contracts + +- [x] 6.1 Require every supported runtime binding to declare input shape, output shape, permission gate, config gate, event shape, and fixture coverage. +- [x] 6.2 Mark bindings without that contract as blocked or design-only rather than runnable. +- [x] 6.3 Project contract truth to Canvas capability/status surfaces without exposing resource internals as user-entered fields. + +## 7. Webhook Real Delivery + +- [x] 7.1 Connect EvidenceBatch/resource projection, send permission, and configured webhook URL for `workflow.notifier.webhook.send`. +- [x] 7.2 Add a real HTTP delivery fixture with deterministic request capture. +- [x] 7.3 Add negative fixtures for missing permission, missing URL, and missing EvidenceBatch/resource projection. +- [x] 7.4 Verify success emits real delivery evidence and all unmet preconditions emit stable blocked evidence. + +## 8. Verification + +- [x] 8.1 Run `openspec validate runtime-conformance-next-granularity --strict`. +- [x] 8.2 Run targeted pytest suites for each implemented layer as it lands. +- [x] 8.3 Run Code Intel Pipeline and Sentrux after implementation slices, recording any baseline debt separately from conformance evidence. diff --git a/openspec/changes/workflow-runtime-conformance/proposal.md b/openspec/changes/workflow-runtime-conformance/proposal.md new file mode 100644 index 0000000..7c778e4 --- /dev/null +++ b/openspec/changes/workflow-runtime-conformance/proposal.md @@ -0,0 +1,34 @@ +## Why + +OpenCLI Admin can project workflow runs and expose run events, but a canvas node +should not claim live runtime support from labels alone. Backend runtime support +needs an executable conformance gate that proves the registry declaration, +fixture execution, and observed event transcript agree. + +## What Changes + +- Define the first workflow runtime conformance contract for the interim + `/api/v1/workflows/runs/{runId}/events` source. +- Add canonical fixture, expected transcript, and passport expectations for the + backend first slice. +- Cover happy path, permission-blocked path, and unsupported missing-binding + path as executable evidence. +- Record that config-blocked, SSE, and ODP/Redis-stream conformance are later + fixture groups, not part of this first backend slice. + +## Capabilities + +### New Capabilities + +- `workflow-runtime-conformance`: Runtime compatibility evidence from workflow + fixtures, expected event transcripts, and generated runtime passports. + +### Modified Capabilities + +- None. + +## Impact + +- Backend workflow conformance helpers and expected transcript goldens. +- Integration tests that hit the public workflow run and run event APIs. +- Generated conformance passports scoped to caller-provided artifact directories. diff --git a/openspec/changes/workflow-runtime-conformance/specs/workflow-runtime-conformance/spec.md b/openspec/changes/workflow-runtime-conformance/specs/workflow-runtime-conformance/spec.md new file mode 100644 index 0000000..01d3e61 --- /dev/null +++ b/openspec/changes/workflow-runtime-conformance/specs/workflow-runtime-conformance/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Runtime support is proved by executable conformance evidence +The backend SHALL treat workflow runtime compatibility as the combination of a +registry declaration, an executable workflow fixture, and an observed event +transcript from the public workflow-run event API. + +#### Scenario: Happy path emits expected runtime evidence +- **WHEN** the canonical conformance workflow runs with deterministic source outputs and configured notification delivery +- **THEN** `/api/v1/workflows/runs/{runId}/events` contains expected transcript evidence for `workflow.source.fetch`, `workflow.transform.normalize`, `workflow.router.route`, `workflow.inbox.store`, and `workflow.notify.send`. + +#### Scenario: Permission absence is blocked, not failed +- **WHEN** the canonical conformance workflow runs without fetch and notification permissions +- **THEN** the run remains `valid=true`, the projection status is `blocked`, and event block reasons include `fetch_permission_required` and `send_permission_required`. + +#### Scenario: Unsupported runtime binding is explicit +- **WHEN** a conformance fixture includes an unsupported node through the normal compile/runtime path +- **THEN** the run remains `valid=true`, the projection status is `blocked`, and the unsupported node emits `missing_runtime_binding`. + +### Requirement: Expected transcripts ignore volatile event fields +The conformance matcher SHALL compare stable event facts while ignoring volatile +fields such as run id, trace id, generated event id, timestamp, and sequence +numbers unless a case explicitly asserts ordering. + +#### Scenario: Stable event facts match +- **WHEN** the matcher evaluates an expected event +- **THEN** it checks node id, event type, optional binding id, optional block reason code, message substring, event details subset, and block reason details subset. + +### Requirement: Runtime passports are generated artifacts +The backend SHALL write generated `opencli-runtime-passport.json` files only +under a caller-provided artifact directory. + +#### Scenario: Passport is artifact-scoped +- **WHEN** the conformance harness writes a runtime passport +- **THEN** the file is created under the provided artifact directory and not at the repository root. + +### Requirement: Later conformance groups remain out of the first slice +The first backend conformance slice SHALL NOT claim config-blocked, SSE stream, +or ODP/Redis-stream certification. + +#### Scenario: Later groups are documented as follow-up +- **WHEN** first-slice evidence is generated +- **THEN** the runtime passport status remains `partial` until later fixture groups are implemented. diff --git a/openspec/changes/workflow-runtime-conformance/tasks.md b/openspec/changes/workflow-runtime-conformance/tasks.md new file mode 100644 index 0000000..080e560 --- /dev/null +++ b/openspec/changes/workflow-runtime-conformance/tasks.md @@ -0,0 +1,19 @@ +## 1. Contract + +- [x] 1.1 Define the workflow runtime conformance OpenSpec capability. +- [x] 1.2 Specify the first-slice event source, fixture expectations, and runtime passport. +- [x] 1.3 Mark config-blocked, SSE, and ODP/Redis stream conformance as later fixture groups. + +## 2. Backend Harness + +- [x] 2.1 Add canonical workflow conformance fixture builders. +- [x] 2.2 Add expected event transcript schemas and matcher. +- [x] 2.3 Add expected transcript golden files for happy, permission-blocked, and missing-binding cases. +- [x] 2.4 Add runtime passport artifact writer scoped to caller-provided output directories. + +## 3. Verification + +- [x] 3.1 Add integration tests for the first conformance slice. +- [x] 3.2 Run `.\.venv\Scripts\python.exe -m pytest tests\integration\test_workflow_conformance.py -q --no-cov`. +- [x] 3.3 Run `openspec validate workflow-runtime-conformance --strict`. +- [x] 3.4 Re-run code-intel/Sentrux checks and record the existing frontend hotspot as baseline debt. diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh index 7726ae1..2bd0d8c 100755 --- a/scripts/install-agent.sh +++ b/scripts/install-agent.sh @@ -78,7 +78,7 @@ if [[ "$INSTALL_CHROME" == "true" ]]; then else CHROME_SUFFIX="" fi -AGENT_IMAGE="xjh1994/opencli-admin-agent:${IMAGE_TAG}${CHROME_SUFFIX}" +AGENT_IMAGE="${DOCKER_IMAGE_NAMESPACE:-2233admin}/opencli-admin-agent:${IMAGE_TAG}${CHROME_SUFFIX}" # ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..10fa52d --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Shared test fixture builders.""" diff --git a/tests/fixtures/workflow_conformance.py b/tests/fixtures/workflow_conformance.py new file mode 100644 index 0000000..8c4950d --- /dev/null +++ b/tests/fixtures/workflow_conformance.py @@ -0,0 +1,275 @@ +"""Canonical workflow runtime conformance fixtures.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +EXPECTED_FIRST_SLICE_BINDINGS = [ + "workflow.source.fetch", + "workflow.transform.normalize", + "workflow.router.route", + "workflow.inbox.store", + "workflow.notify.send", +] + + +def workflow_conformance_project( + *, + can_fetch_network: bool = True, + can_send_notifications: bool = True, + delivery_configured: bool = True, + include_unsupported_node: bool = False, +) -> dict[str, Any]: + notify_config: dict[str, Any] = { + "notifierType": "operator-preview", + "target": "simulated-webhook", + } + if delivery_configured: + notify_config["url"] = "mock://opencli-conformance/notify" + + project: dict[str, Any] = { + "id": "workflow-runtime-conformance", + "name": "Runtime Conformance", + "profile": "intelligence", + "version": 1, + "settings": { + "timezone": "Asia/Shanghai", + "deterministicSimulation": True, + "maxItemsPerRun": 20, + }, + "adapters": [ + { + "id": "jin10-kuaixun", + "type": "source", + "provider": "jin10", + "mode": "fixture", + "config": {"feed": "kuaixun"}, + }, + { + "id": "simulated-webhook", + "type": "notification", + "provider": "operator-preview", + "mode": "mock", + "config": notify_config, + }, + ], + "agentPermissions": { + "canFetchNetwork": can_fetch_network, + "canSendNotifications": can_send_notifications, + "canWriteInbox": True, + "allowedDomains": ["jin10.com"], + }, + "nodes": [ + { + "id": "source-jin10", + "kind": "source", + "capability": "fetch", + "adapter": "jin10-kuaixun", + "params": {"limit": 20, "importantOnly": False}, + }, + { + "id": "agent-normalize", + "kind": "agent", + "capability": "normalize", + "params": {"language": "zh-CN"}, + }, + { + "id": "router-importance", + "kind": "router", + "capability": "route", + "params": {"expression": "item.important === true || item.score >= 0.7"}, + }, + { + "id": "inbox-review", + "kind": "inbox", + "capability": "store", + "params": {"queue": "macro-watch"}, + }, + { + "id": "notify-preview", + "kind": "notify", + "capability": "send", + "adapter": "simulated-webhook", + "params": {"template": "brief", "target": "simulated-webhook"}, + }, + ], + "edges": [ + { + "id": "e-source-normalize", + "source": "source-jin10", + "target": "agent-normalize", + }, + { + "id": "e-normalize-router", + "source": "agent-normalize", + "target": "router-importance", + }, + { + "id": "e-router-inbox", + "source": "router-importance", + "target": "inbox-review", + }, + { + "id": "e-router-notify", + "source": "router-importance", + "target": "notify-preview", + }, + ], + } + + if include_unsupported_node: + project["nodes"].append( + { + "id": "unsupported-export", + "kind": "action", + "capability": "summarize", + "params": {"format": "pdf"}, + } + ) + project["edges"].append( + { + "id": "e-router-unsupported", + "source": "router-importance", + "target": "unsupported-export", + } + ) + + return project + + +def workflow_conformance_source_outputs() -> dict[str, list[dict[str, Any]]]: + return { + "source-jin10": [ + { + "id": "macro-1", + "title": "Fed signal: watch rates", + "url": "https://www.jin10.com/flash/macro-1", + "important": True, + "score": 0.91, + }, + { + "id": "macro-2", + "title": "Low-signal item", + "url": "https://www.jin10.com/flash/macro-2", + "important": False, + "score": 0.22, + }, + ] + } + + +def workflow_conformance_missing_webhook_url_project() -> dict[str, Any]: + project = workflow_conformance_project() + project["adapters"] = [ + adapter for adapter in project["adapters"] if adapter["id"] != "simulated-webhook" + ] + project["adapters"].append( + { + "id": "webhook-notifier", + "type": "notification", + "provider": "webhook", + "mode": "live", + "config": { + "notifierType": "webhook", + "target": "webhook", + }, + } + ) + for node in project["nodes"]: + if node["id"] == "notify-preview": + node.update( + { + "id": "notify-webhook", + "adapter": "webhook-notifier", + "params": {"template": "brief", "target": "webhook"}, + "ui": {"catalogId": "intelligence.output.webhook"}, + } + ) + for edge in project["edges"]: + if edge["target"] == "notify-preview": + edge["target"] = "notify-webhook" + return project + + +def workflow_conformance_webhook_delivery_project( + *, + can_send_notifications: bool = True, + include_projection_edge: bool = True, +) -> dict[str, Any]: + project = workflow_conformance_missing_webhook_url_project() + project["agentPermissions"]["canSendNotifications"] = can_send_notifications + for adapter in project["adapters"]: + if adapter["id"] == "webhook-notifier": + adapter["config"] = { + **adapter["config"], + "url": "https://hooks.example.com/opencli-conformance", + } + if not include_projection_edge: + project["edges"] = [ + edge for edge in project["edges"] if edge["target"] != "notify-webhook" + ] + return project + + +def workflow_conformance_missing_source_credential_project() -> dict[str, Any]: + project = workflow_conformance_project() + for adapter in project["adapters"]: + if adapter["id"] == "jin10-kuaixun": + adapter["mode"] = "live" + adapter["config"] = { + **adapter["config"], + "requiresCredential": True, + "requiredCredentialKey": "jin10_api_token", + } + return project + + +def workflow_conformance_missing_runtime_resource_project() -> dict[str, Any]: + return { + "id": "workflow-runtime-conformance-resource", + "name": "Runtime Conformance Resource", + "profile": "intelligence", + "version": 1, + "nodes": [ + { + "id": "publish-turbopush", + "kind": "notify", + "capability": "send", + "adapter": "turbopush-local", + "params": { + "contentType": "graph_text", + "contentSource": "upstream", + "title": "{{item.title}}", + "desc": "{{item.summary}}", + "targetPlatforms": ["xiaohongshu"], + "accountSelector": "logged_accounts_by_platform", + "syncDraft": False, + }, + "ui": {"catalogId": "intelligence.output.turbopush-publish"}, + } + ], + "edges": [], + "adapters": [ + { + "id": "turbopush-local", + "type": "notification", + "provider": "turbopush", + "mode": "live", + "config": { + "channel": "turbopush", + "mcpServer": "turbo-push", + "resourceMode": "auto", + }, + } + ], + "agentPermissions": { + "canFetchNetwork": True, + "canSendNotifications": True, + "canWriteInbox": True, + }, + } + + +def copy_workflow_fixture(project: dict[str, Any]) -> dict[str, Any]: + return deepcopy(project) diff --git a/tests/integration/test_generic_webhook_live.py b/tests/integration/test_generic_webhook_live.py new file mode 100644 index 0000000..86dd4c3 --- /dev/null +++ b/tests/integration/test_generic_webhook_live.py @@ -0,0 +1,97 @@ +"""Live generic webhook delivery acceptance tests.""" + +from __future__ import annotations + +import asyncio +import os +import re +import time + +import httpx +import pytest + +from backend.workflow.webhook_delivery import ( + WEBHOOK_DELIVERY_EVENT, + WEBHOOK_DELIVERY_PAYLOAD_SCHEMA, + execute_workflow_webhook_delivery, +) + +WEBHOOK_SITE_TOKEN_API = "https://webhook.site/token" +WEBHOOK_SITE_URL_RE = re.compile( + r"^https://webhook\.site/(?P[0-9a-fA-F-]{36})(?:[/?#].*)?$" +) + + +@pytest.mark.live +@pytest.mark.asyncio +async def test_generic_webhook_live_delivery_posts_public_http_request() -> None: + target_url, webhook_site_token = await _resolve_live_webhook_target() + run_id = f"generic-webhook-live-{int(time.time())}" + + result = await execute_workflow_webhook_delivery( + { + "target": "generic-webhook-live", + "url": target_url, + "config": {"timeout": 30}, + }, + [ + { + "raw": { + "id": "live-item-1", + "title": "WSL generic webhook live acceptance", + "url": "https://example.com/opencli-admin-backend/live-webhook", + }, + "lineage": [{"source": "pytest-live", "runId": run_id}], + } + ], + workflow_id="opencli-admin-backend", + run_id=run_id, + node_id="notify-webhook", + ) + + assert result == { + "notifierType": "webhook", + "target": "generic-webhook-live", + "deliveryAttempted": True, + "delivered": True, + "event": WEBHOOK_DELIVERY_EVENT, + "payloadSchema": WEBHOOK_DELIVERY_PAYLOAD_SCHEMA, + "itemCount": 1, + } + + if webhook_site_token: + captured = await _read_webhook_site_latest_payload(webhook_site_token) + assert captured["event"] == WEBHOOK_DELIVERY_EVENT + assert captured["source_id"] == "opencli-admin-backend" + assert captured["record_id"] == run_id + assert captured["data"]["schema"] == WEBHOOK_DELIVERY_PAYLOAD_SCHEMA + assert captured["data"]["nodeId"] == "notify-webhook" + assert captured["data"]["items"][0]["title"] == "WSL generic webhook live acceptance" + + +async def _resolve_live_webhook_target() -> tuple[str, str | None]: + configured_url = os.environ.get("OPENCLI_GENERIC_WEBHOOK_LIVE_URL", "").strip() + if configured_url: + return configured_url, _webhook_site_token_from_url(configured_url) + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(WEBHOOK_SITE_TOKEN_API) + response.raise_for_status() + token = response.json()["uuid"] + return f"https://webhook.site/{token}", token + + +def _webhook_site_token_from_url(url: str) -> str | None: + match = WEBHOOK_SITE_URL_RE.match(url) + return match.group("token") if match else None + + +async def _read_webhook_site_latest_payload(token: str) -> dict: + latest_url = f"{WEBHOOK_SITE_TOKEN_API}/{token}/request/latest/raw" + async with httpx.AsyncClient(timeout=30) as client: + for _ in range(10): + response = await client.get(latest_url, headers={"accept": "application/json"}) + if response.status_code == 200 and response.text.strip(): + return response.json() + await asyncio.sleep(1) + raise AssertionError("Webhook.site did not expose the latest request payload in time") diff --git a/tests/integration/test_workflow_capabilities_api.py b/tests/integration/test_workflow_capabilities_api.py index efd6ce3..0035740 100644 --- a/tests/integration/test_workflow_capabilities_api.py +++ b/tests/integration/test_workflow_capabilities_api.py @@ -120,6 +120,10 @@ async def test_compile_reports_webhook_notify_contract_without_live_delivery(cli assert node["runtime"]["notifier"]["binding_id"] == "workflow.notifier.webhook.send" assert node["runtime"]["notifier"]["dispatch"] == "blocked_until_projection" assert node["runtime"]["notifier"]["input"]["delivery_configured"] is False + notifier_contract = node["runtime"]["notifier"]["contract"] + assert notifier_contract["bindingId"] == "workflow.notifier.webhook.send" + assert notifier_contract["certification"]["realNodeIoContract"] is True + assert notifier_contract["certification"]["realWebhookDelivery"] is True assert node["runtime"]["missing_runtime"] == { "status": "missing", "code": "missing_delivery_projection", @@ -161,6 +165,16 @@ async def test_workflow_capabilities_project_real_backend_surfaces(client, monke data = response.json()["data"] catalog = {item["id"]: item for item in data["catalog"]} + for item in catalog.values(): + if item["status"] == "runnable" and item.get("runtimeBinding"): + contract = item["manifest"]["contract"] + assert contract["bindingId"] == item["runtimeBinding"] + assert contract["inputShape"]["ports"] is not None + assert contract["outputShape"]["ports"] is not None + assert contract["eventShape"]["events"] + assert contract["fixtureCoverage"]["cases"] + assert contract["canvas"]["exposeResourceInternals"] is False + assert catalog["intelligence.input.collection-need"]["status"] == "runnable" assert catalog["intelligence.input.collection-need"]["backendAvailable"] is True assert catalog["intelligence.input.collection-need"]["runtimeBinding"] == ( @@ -256,7 +270,15 @@ async def test_workflow_capabilities_project_real_backend_surfaces(client, monke "workflow.notifier.webhook.send" ) assert "workflow_notifier_sink_binding" not in catalog["intelligence.output.webhook"]["missing"] - assert "delivery_projection" in catalog["intelligence.output.webhook"]["missing"] + assert "evidencebatch_projection_input" in catalog["intelligence.output.webhook"]["missing"] + webhook_contract = catalog["intelligence.output.webhook"]["manifest"]["contract"] + assert webhook_contract["status"] == "blocked_until_preconditions" + assert webhook_contract["certification"]["realWebhookDelivery"] is True + assert webhook_contract["configGate"]["required"] == [ + "evidencebatch_projection_api", + "delivery_projection", + "webhook_url", + ] channels = {item["channelType"]: item for item in data["channels"]} assert set(channels) == { @@ -279,7 +301,7 @@ async def test_workflow_capabilities_project_real_backend_surfaces(client, monke assert notifiers["webhook"]["status"] == "blocked" assert notifiers["webhook"]["backendAvailable"] is True assert notifiers["webhook"]["runtimeBinding"] == "workflow.notifier.webhook.send" - assert "delivery_projection" in notifiers["webhook"]["missing"] + assert "evidencebatch_projection_input" in notifiers["webhook"]["missing"] primitives = {item["id"]: item for item in data["primitives"]} assert primitives["primitive.ops.trigger-webhook"]["status"] == "blocked" diff --git a/tests/integration/test_workflow_compile_api.py b/tests/integration/test_workflow_compile_api.py index 47c8f63..c14f305 100644 --- a/tests/integration/test_workflow_compile_api.py +++ b/tests/integration/test_workflow_compile_api.py @@ -3,6 +3,13 @@ import pytest +def _assert_binding_includes(actual: dict, expected: dict) -> None: + for key, value in expected.items(): + assert actual.get(key) == value + if "contract" in actual: + assert actual["contract"]["bindingId"] == expected["binding_id"] + + def _valid_workflow_project() -> dict: return { "id": "wf-opencli-multi-source", @@ -274,7 +281,7 @@ async def test_compile_resolves_opencli_source_to_iii_runtime_binding(client): runtime = response.json()["data"]["plan"]["runtime"] source_node = runtime["nodes"][0] assert source_node["id"] == "source-bilibili" - assert source_node["runtime"]["binding"] == { + _assert_binding_includes(source_node["runtime"]["binding"], { "status": "bound", "binding_id": "iii.collector-opencli.snapshot", "runtime": "iii", @@ -282,7 +289,7 @@ async def test_compile_resolves_opencli_source_to_iii_runtime_binding(client): "function_id": "odp.collect::opencli_snapshot", "channel": "opencli", "input": {"site": "bilibili", "command": "search"}, - } + }) @pytest.mark.asyncio @@ -407,7 +414,7 @@ async def test_compile_resolves_normalize_to_native_transform_binding(client): runtime_nodes = response.json()["data"]["plan"]["runtime"]["nodes"] normalize_node = runtime_nodes[1] assert normalize_node["id"] == "normalize-items" - assert normalize_node["runtime"]["binding"] == { + _assert_binding_includes(normalize_node["runtime"]["binding"], { "status": "bound", "binding_id": "workflow.transform.normalize", "runtime": "workflow", @@ -418,7 +425,7 @@ async def test_compile_resolves_normalize_to_native_transform_binding(client): "inputPort": "items[]", "outputPort": "recordCandidate[]", }, - } + }) assert normalize_node["runtime"]["normalize"] == { "node_id": "normalize-items", "candidate_port": "recordCandidate[]", @@ -1026,7 +1033,7 @@ async def test_compile_resolves_schedule_trigger_binding(client): if node["id"] == "schedule-cron" ) assert node["runtime"]["origin"]["catalog_id"] == "intelligence.schedule.cron" - assert node["runtime"]["binding"] == { + _assert_binding_includes(node["runtime"]["binding"], { "status": "bound", "binding_id": "workflow.trigger.schedule_tick", "runtime": "workflow", @@ -1036,7 +1043,7 @@ async def test_compile_resolves_schedule_trigger_binding(client): "timezone": "Asia/Shanghai", "enabled": True, }, - } + }) assert node["runtime"]["trigger"] == { "node_id": "schedule-cron", "mode": "manual_schedule_tick", diff --git a/tests/integration/test_workflow_conformance.py b/tests/integration/test_workflow_conformance.py new file mode 100644 index 0000000..ae50a22 --- /dev/null +++ b/tests/integration/test_workflow_conformance.py @@ -0,0 +1,586 @@ +"""Executable workflow runtime conformance tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest + +from backend.workflow.block_reasons import WORKFLOW_BLOCK_REASON_TAXONOMY +from backend.workflow.conformance import ( + ConformanceCaseResult, + load_expected_events, + match_expected_events, + parse_sse_node_events, + write_runtime_passport, +) +from backend.workflow.event_mirror import ( + DEFAULT_WORKFLOW_EVENT_STREAM, + WORKFLOW_EVENT_MIRROR_PROVIDER, + list_workflow_event_mirror_records, + list_workflow_event_mirror_transcript, +) +from backend.workflow.runtime_contracts import list_runtime_io_contracts +from tests.fixtures.workflow_conformance import ( + EXPECTED_FIRST_SLICE_BINDINGS, + workflow_conformance_missing_runtime_resource_project, + workflow_conformance_missing_source_credential_project, + workflow_conformance_missing_webhook_url_project, + workflow_conformance_project, + workflow_conformance_source_outputs, + workflow_conformance_webhook_delivery_project, +) + +EXPECTED_EVENTS_DIR = ( + Path(__file__).parents[2] / "backend" / "workflow" / "conformance" / "expected_events" +) + + +class _FakeRedisStream: + def __init__(self) -> None: + self.entries: list[tuple[str, str, dict[str, str]]] = [] + self.closed = False + + async def xadd(self, stream: str, fields: dict[str, str]) -> str: + entry_id = f"fake-{len(self.entries) + 1}" + self.entries.append((stream, entry_id, fields)) + return entry_id + + async def xrange(self, stream: str, min: str = "-", max: str = "+"): + return [ + (entry_id, fields) + for entry_stream, entry_id, fields in self.entries + if entry_stream == stream + ] + + async def aclose(self) -> None: + self.closed = True + + +def test_workflow_conformance_block_reason_taxonomy_defines_next_layer_codes(): + expected_categories = { + "fetch_permission_required": "missing_permission", + "send_permission_required": "missing_permission", + "missing_delivery_projection": "missing_config", + "missing_source_credential": "missing_source_credential", + "missing_turbopush_service": "missing_runtime_resource", + "missing_runtime_binding": "missing_runtime_binding", + "missing_runtime_io_contract": "missing_runtime_binding", + } + + for code, category in expected_categories.items(): + definition = WORKFLOW_BLOCK_REASON_TAXONOMY[code] + assert definition.category == category + assert definition.stable_fields + + +def test_workflow_conformance_real_node_io_contract_manifest_is_complete(): + required_keys = { + "bindingId", + "inputShape", + "outputShape", + "permissionGate", + "configGate", + "eventShape", + "fixtureCoverage", + "certification", + } + + contracts = list_runtime_io_contracts() + assert contracts + for contract in contracts: + manifest = contract.to_manifest() + assert required_keys.issubset(manifest) + assert manifest["bindingId"] == contract.binding_id + assert isinstance(manifest["inputShape"]["ports"], list) + assert isinstance(manifest["outputShape"]["ports"], list) + assert manifest["eventShape"]["events"] + assert manifest["fixtureCoverage"]["cases"] + assert manifest["certification"]["realNodeIoContract"] is True + + webhook_contract = next( + contract + for contract in contracts + if contract.binding_id == "workflow.notifier.webhook.send" + ).to_manifest() + assert webhook_contract["status"] == "blocked_until_preconditions" + assert webhook_contract["certification"]["realWebhookDelivery"] is True + assert webhook_contract["configGate"]["required"] == [ + "evidencebatch_projection_api", + "delivery_projection", + "webhook_url", + ] + + +@pytest.mark.asyncio +async def test_workflow_conformance_happy_path_matches_expected_transcript(client): + run_id = "conformance-happy-path" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_project(), + "runId": run_id, + "traceId": "trace-conformance-happy-path", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "completed" + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "happy-path.json"), + ) + + assert match.passed, match.failures + + +@pytest.mark.asyncio +async def test_workflow_conformance_compile_projects_real_node_io_contracts(client): + response = await client.post( + "/api/v1/workflows/compile", + json={"project": workflow_conformance_project()}, + ) + + assert response.status_code == 200 + data = response.json()["data"] + assert data["valid"] is True + bound_nodes = [ + node + for node in data["plan"]["runtime"]["nodes"] + if isinstance(node["runtime"].get("binding"), dict) + ] + assert bound_nodes + + for node in bound_nodes: + binding = node["runtime"]["binding"] + contract = binding["contract"] + assert contract["bindingId"] == binding["binding_id"] + assert contract["certification"]["realNodeIoContract"] is True + assert set(contract) >= { + "inputShape", + "outputShape", + "permissionGate", + "configGate", + "eventShape", + "fixtureCoverage", + } + + +@pytest.mark.asyncio +async def test_workflow_conformance_sse_stream_matches_snapshot_transcript(client): + run_id = "conformance-sse-parity" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_project(), + "runId": run_id, + "traceId": "trace-conformance-sse-parity", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + expected = load_expected_events(EXPECTED_EVENTS_DIR / "happy-path.json") + + snapshot_events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + snapshot_match = match_expected_events(snapshot_events, expected) + assert snapshot_match.passed, snapshot_match.failures + + stream_response = await client.get(f"/api/v1/workflows/runs/{run_id}/events/stream") + assert stream_response.status_code == 200 + assert stream_response.headers["content-type"].startswith("text/event-stream") + stream_events = parse_sse_node_events(stream_response.text) + stream_match = match_expected_events(stream_events, expected) + assert stream_match.passed, stream_match.failures + + +@pytest.mark.asyncio +async def test_workflow_conformance_redis_event_mirror_matches_snapshot_transcript( + client, + monkeypatch, +): + import redis.asyncio as aioredis + + fake_redis = _FakeRedisStream() + monkeypatch.setenv("WORKFLOW_EVENT_MIRROR_BACKEND", "redis") + monkeypatch.setenv("WORKFLOW_EVENT_MIRROR_REDIS_URL", "redis://conformance-redis/0") + monkeypatch.setenv("WORKFLOW_EVENT_MIRROR_STREAM", DEFAULT_WORKFLOW_EVENT_STREAM) + monkeypatch.setattr(aioredis, "from_url", lambda *args, **kwargs: fake_redis) + + run_id = "conformance-redis-event-mirror" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_project(), + "runId": run_id, + "traceId": "trace-conformance-redis-event-mirror", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + assert {entry[0] for entry in fake_redis.entries} == {DEFAULT_WORKFLOW_EVENT_STREAM} + + expected = load_expected_events(EXPECTED_EVENTS_DIR / "happy-path.json") + mirror_events = await list_workflow_event_mirror_transcript( + run_id, + backend="redis", + stream=DEFAULT_WORKFLOW_EVENT_STREAM, + ) + mirror_match = match_expected_events(mirror_events, expected) + assert mirror_match.passed, mirror_match.failures + + mirror_records = await list_workflow_event_mirror_records( + run_id, + backend="redis", + stream=DEFAULT_WORKFLOW_EVENT_STREAM, + ) + assert mirror_records + assert {record.provider for record in mirror_records} == {WORKFLOW_EVENT_MIRROR_PROVIDER} + assert {record.ingest_mode for record in mirror_records} == {"stream"} + assert all(record.stable_facts["nodeId"] for record in mirror_records) + + +@pytest.mark.asyncio +async def test_workflow_conformance_missing_webhook_url_blocks_with_stable_reason(client): + run_id = "conformance-missing-webhook-url" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_missing_webhook_url_project(), + "runId": run_id, + "traceId": "trace-conformance-missing-webhook-url", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + states = {state["nodeId"]: state for state in projection["nodeStates"]} + assert states["notify-webhook"]["blockReasons"][0]["code"] == ("missing_delivery_projection") + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "missing-webhook-url.json"), + ) + + assert match.passed, match.failures + + +@pytest.mark.asyncio +async def test_workflow_conformance_webhook_real_delivery_emits_http_request( + client, + monkeypatch, +): + captured_requests: list[httpx.Request] = [] + + async def fake_guarded_async_client(url: str, **client_kwargs): + async def handler(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response(202, json={"ok": True}, request=request) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)), url + + monkeypatch.setattr( + "backend.notifiers.webhook_notifier.guarded_async_client", + fake_guarded_async_client, + ) + + run_id = "conformance-webhook-real-delivery" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_webhook_delivery_project(), + "runId": run_id, + "traceId": "trace-conformance-webhook-real-delivery", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "completed" + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "webhook-real-delivery.json"), + ) + assert match.passed, match.failures + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == "POST" + assert str(request.url) == "https://hooks.example.com/opencli-conformance" + body = json.loads(request.content.decode("utf-8")) + assert body["event"] == "workflow.evidence_batch.ready" + assert body["source_id"] == "workflow-runtime-conformance" + assert body["record_id"] == run_id + assert body["data"]["schema"] == "workflow.webhook.evidence_batch.v1" + assert body["data"]["workflowRunId"] == run_id + assert body["data"]["nodeId"] == "notify-webhook" + assert body["data"]["itemCount"] == 1 + assert body["data"]["items"][0]["title"] == "Fed signal: watch rates" + + +@pytest.mark.asyncio +async def test_workflow_conformance_webhook_delivery_blocks_without_send_permission( + client, + monkeypatch, +): + captured_requests: list[httpx.Request] = [] + + async def fake_guarded_async_client(url: str, **client_kwargs): + async def handler(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response(202, request=request) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)), url + + monkeypatch.setattr( + "backend.notifiers.webhook_notifier.guarded_async_client", + fake_guarded_async_client, + ) + + run_id = "conformance-webhook-missing-permission" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_webhook_delivery_project( + can_send_notifications=False + ), + "runId": run_id, + "traceId": "trace-conformance-webhook-missing-permission", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + states = {state["nodeId"]: state for state in projection["nodeStates"]} + reason = states["notify-webhook"]["blockReasons"][0] + assert reason["code"] == "send_permission_required" + assert reason["details"]["bindingId"] == "workflow.notifier.webhook.send" + assert captured_requests == [] + + +@pytest.mark.asyncio +async def test_workflow_conformance_webhook_delivery_blocks_without_projection( + client, + monkeypatch, +): + captured_requests: list[httpx.Request] = [] + + async def fake_guarded_async_client(url: str, **client_kwargs): + async def handler(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response(202, request=request) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)), url + + monkeypatch.setattr( + "backend.notifiers.webhook_notifier.guarded_async_client", + fake_guarded_async_client, + ) + + run_id = "conformance-webhook-missing-projection" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_webhook_delivery_project( + include_projection_edge=False + ), + "runId": run_id, + "traceId": "trace-conformance-webhook-missing-projection", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + states = {state["nodeId"]: state for state in projection["nodeStates"]} + reason = states["notify-webhook"]["blockReasons"][0] + assert reason["code"] == "missing_delivery_projection" + assert reason["details"]["bindingId"] == "workflow.notifier.webhook.send" + assert reason["details"]["required_params"] == [ + "evidencebatch_projection_api", + "delivery_projection", + ] + assert captured_requests == [] + + +@pytest.mark.asyncio +async def test_workflow_conformance_missing_source_credential_blocks_with_stable_reason( + client, +): + run_id = "conformance-missing-source-credential" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_missing_source_credential_project(), + "runId": run_id, + "traceId": "trace-conformance-missing-source-credential", + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + states = {state["nodeId"]: state for state in projection["nodeStates"]} + assert states["source-jin10"]["blockReasons"][0]["code"] == "missing_source_credential" + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "missing-source-credential.json"), + ) + + assert match.passed, match.failures + + +@pytest.mark.asyncio +async def test_workflow_conformance_missing_runtime_resource_blocks_with_stable_reason( + client, + monkeypatch, + tmp_path, +): + monkeypatch.delenv("TURBO_PUSH_PORT", raising=False) + monkeypatch.delenv("TURBO_PUSH_AUTH", raising=False) + monkeypatch.setenv("TURBO_PUSH_MCP_CONFIG", str(tmp_path / "missing-mcp.json")) + + run_id = "conformance-missing-runtime-resource" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_missing_runtime_resource_project(), + "runId": run_id, + "traceId": "trace-conformance-missing-runtime-resource", + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + states = {state["nodeId"]: state for state in projection["nodeStates"]} + assert states["publish-turbopush"]["blockReasons"][0]["code"] == ("missing_turbopush_service") + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "missing-runtime-resource.json"), + ) + + assert match.passed, match.failures + + +@pytest.mark.asyncio +async def test_workflow_conformance_permission_blocked_transcript(client): + run_id = "conformance-permission-blocked" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_project( + can_fetch_network=False, + can_send_notifications=False, + ), + "runId": run_id, + "traceId": "trace-conformance-permission-blocked", + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "permission-blocked.json"), + ) + + assert match.passed, match.failures + + +@pytest.mark.asyncio +async def test_workflow_conformance_missing_binding_transcript(client): + run_id = "conformance-missing-binding" + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": workflow_conformance_project(include_unsupported_node=True), + "runId": run_id, + "traceId": "trace-conformance-missing-binding", + "sourceOutputs": workflow_conformance_source_outputs(), + }, + ) + + assert response.status_code == 202 + projection = response.json()["data"] + assert projection["valid"] is True + assert projection["status"] == "blocked" + + events = (await client.get(f"/api/v1/workflows/runs/{run_id}/events")).json()["data"] + match = match_expected_events( + events, + load_expected_events(EXPECTED_EVENTS_DIR / "missing-binding.json"), + ) + + assert match.passed, match.failures + + +def test_workflow_conformance_passport_is_artifact_scoped(tmp_path): + passport_path = write_runtime_passport( + tmp_path / "opencli-conformance" / "local-run", + [ + ConformanceCaseResult( + id="happy-path", + status="passed", + bindings=EXPECTED_FIRST_SLICE_BINDINGS, + ), + ConformanceCaseResult( + id="permission-blocked", + status="passed", + bindings=["workflow.source.fetch", "workflow.notify.send"], + blockedReasons=[ + "fetch_permission_required", + "send_permission_required", + ], + ), + ConformanceCaseResult( + id="missing-binding", + status="passed", + bindings=[], + blockedReasons=["missing_runtime_binding"], + ), + ], + ) + + assert passport_path.name == "opencli-runtime-passport.json" + assert passport_path.parent.name == "local-run" + passport = json.loads(passport_path.read_text(encoding="utf-8")) + assert passport["schemaVersion"] == 1 + assert passport["eventSource"] == "workflow-run-events" + assert passport["status"] == "partial" + assert passport["bindings"]["workflow.notify.send"]["status"] == ("conformance-known") + assert ( + "send_permission_required" + in (passport["bindings"]["workflow.notify.send"]["blockedReasons"]) + ) diff --git a/tests/integration/test_workflow_opencli_hda_trace_api.py b/tests/integration/test_workflow_opencli_hda_trace_api.py index be6cf08..8608a49 100644 --- a/tests/integration/test_workflow_opencli_hda_trace_api.py +++ b/tests/integration/test_workflow_opencli_hda_trace_api.py @@ -13,6 +13,7 @@ from backend.models.task import CollectionTask from backend.models.workflow_run import WorkflowRun, WorkflowRunEvent from backend.workflow.opencli_hda_tracer import _RUNS +from tests.fixtures.workflow_conformance import workflow_conformance_project def _multi_source_opencli_hda_project() -> dict: @@ -311,6 +312,14 @@ def _native_first_loop_project() -> dict: } +def _legacy_canvas_intelligence_project() -> dict: + return workflow_conformance_project( + can_fetch_network=False, + can_send_notifications=False, + delivery_configured=False, + ) + + async def _seed_collected_record( db_session, *, @@ -845,6 +854,93 @@ async def test_workflow_run_emits_native_first_loop_trace_events(client, db_sess assert {task.parameters["workflowRunId"] for task in tasks} == {"run-native-first-loop"} +@pytest.mark.asyncio +async def test_workflow_run_resolves_legacy_canvas_runtime_bindings(client, db_session): + response = await client.post( + "/api/v1/workflows/runs", + json={ + "project": _legacy_canvas_intelligence_project(), + "runId": "run-legacy-canvas-bindings", + "traceId": "trace-legacy-canvas-bindings", + "sourceOutputs": { + "source-jin10": [ + { + "title": "Important macro flash", + "url": "https://www.jin10.com/flash/important", + "important": True, + "score": 0.91, + }, + { + "title": "Low priority flash", + "url": "https://www.jin10.com/flash/low", + "important": False, + "score": 0.2, + }, + ], + }, + }, + ) + + assert response.status_code == 202 + data = response.json()["data"] + assert data["valid"] is True + assert data["status"] == "blocked" + states = {state["nodeId"]: state for state in data["nodeStates"]} + assert states["source-jin10"]["status"] == "completed" + assert states["agent-normalize"]["status"] == "completed" + assert states["router-importance"]["status"] == "completed" + assert states["inbox-review"]["status"] == "completed" + assert states["notify-preview"]["status"] == "blocked" + assert states["notify-preview"]["blockReasons"][0]["code"] == "send_permission_required" + assert all( + reason["code"] != "missing_runtime_binding" + for state in data["nodeStates"] + for reason in state["blockReasons"] + ) + + events = ( + await client.get("/api/v1/workflows/runs/run-legacy-canvas-bindings/events") + ).json()["data"] + by_node = {} + for event in events: + by_node.setdefault(event["nodeId"], []).append(event) + + source_partial = by_node["source-jin10"][2] + assert source_partial["details"]["itemCount"] == 2 + assert source_partial["details"]["outputPort"] == "items[]" + + router_partial = by_node["router-importance"][2] + assert router_partial["details"]["bindingId"] == "workflow.router.route" + assert router_partial["details"]["inputCandidateCount"] == 2 + assert router_partial["details"]["routedCandidateCount"] == 1 + + inbox_partial = by_node["inbox-review"][2] + assert inbox_partial["details"]["bindingId"] == "workflow.inbox.store" + assert inbox_partial["details"]["target"] == "macro-watch" + assert inbox_partial["details"]["inputRecordCount"] == 1 + assert inbox_partial["details"]["storedRecordCount"] == 1 + + notify_events = by_node["notify-preview"] + assert [event["eventType"] for event in notify_events] == [ + "queued", + "started", + "blocked", + ] + assert notify_events[-1]["blockReason"]["details"]["bindingId"] == ( + "workflow.notify.send" + ) + + records = ( + (await db_session.execute(select(CollectedRecord).order_by(CollectedRecord.created_at))) + .scalars() + .all() + ) + assert len(records) == 1 + assert records[0].normalized_data["title"] == "Important macro flash" + assert records[0].raw_data["_workflowRunId"] == "run-legacy-canvas-bindings" + assert records[0].raw_data["_workflowSinkNodeId"] == "inbox-review" + + @pytest.mark.asyncio async def test_workflow_run_loads_bound_source_task_records_as_items(client, db_session): _bili_source, bili_task, _bili_record = await _seed_collected_record(