From acfacfe1b3a5a8ab9c5173eeb48b12935ffc798b Mon Sep 17 00:00:00 2001 From: chenchen Date: Tue, 14 Jul 2026 11:28:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B7=A5=E4=BD=9C=E6=B5=81)=EF=BC=9A?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=E5=9B=BE=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=8C=85=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: define local workflow package mvp * docs: fix workflow package api contract * feat: add local workflow package mvp * feat(workflow): add package API client * feat(workflow): add package import interface * feat(workflow): connect package import flow * fix(workflow): map package validation errors * fix(workflow): handle import key generation errors * feat(workflow): localize package import states * fix(workflow): reset package import modal on open --------- Co-authored-by: ruanmingchen <“ruanm@chenchen”> --- .../2026-07-13-local-workflow-package-mvp.md | 99 ++++ ...ocal-workflow-package-api-data-contract.md | 334 +++++++++++++ ...07-13-local-workflow-package-mvp-design.md | 134 ++++++ internal/app/app.go | 17 + internal/database/database.go | 33 +- internal/database/workflow_package.go | 286 +++++++++++ internal/database/workflow_package_test.go | 74 +++ internal/handler/workflow_package.go | 319 +++++++++++++ internal/handler/workflow_package_test.go | 78 +++ internal/security/rbac_middleware.go | 7 + .../security/workflow_package_rbac_test.go | 20 + internal/workflow/package/exporter.go | 98 ++++ internal/workflow/package/exporter_test.go | 58 +++ internal/workflow/package/inspector.go | 225 +++++++++ internal/workflow/package/inspector_test.go | 154 ++++++ internal/workflow/package/manifest.go | 78 +++ web/static/css/style.css | 234 +++++++++ web/static/i18n/en-US.json | 87 ++++ web/static/i18n/zh-CN.json | 87 ++++ web/static/js/workflow-package-client.js | 117 +++++ .../js/workflow-package-client.test.cjs | 51 ++ web/static/js/workflow-package-ui.test.cjs | 70 +++ web/static/js/workflows.js | 443 ++++++++++++++++++ web/templates/index.html | 54 +++ 24 files changed, 3154 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-13-local-workflow-package-mvp.md create mode 100644 docs/superpowers/specs/2026-07-13-local-workflow-package-api-data-contract.md create mode 100644 docs/superpowers/specs/2026-07-13-local-workflow-package-mvp-design.md create mode 100644 internal/database/workflow_package.go create mode 100644 internal/database/workflow_package_test.go create mode 100644 internal/handler/workflow_package.go create mode 100644 internal/handler/workflow_package_test.go create mode 100644 internal/security/workflow_package_rbac_test.go create mode 100644 internal/workflow/package/exporter.go create mode 100644 internal/workflow/package/exporter_test.go create mode 100644 internal/workflow/package/inspector.go create mode 100644 internal/workflow/package/inspector_test.go create mode 100644 internal/workflow/package/manifest.go create mode 100644 web/static/js/workflow-package-client.js create mode 100644 web/static/js/workflow-package-client.test.cjs create mode 100644 web/static/js/workflow-package-ui.test.cjs diff --git a/docs/superpowers/plans/2026-07-13-local-workflow-package-mvp.md b/docs/superpowers/plans/2026-07-13-local-workflow-package-mvp.md new file mode 100644 index 00000000..79ca8a73 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-local-workflow-package-mvp.md @@ -0,0 +1,99 @@ +# Local Workflow Package MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add secure, deterministic single-workflow ZIP export plus two-step, idempotent local package import without changing existing workflow APIs. + +**Architecture:** `internal/workflow/package` owns package format, deterministic ZIP construction, archive inspection and import orchestration. `internal/database` owns SQLite schema, lifecycle state and the one transaction that rechecks the inspection snapshot, changes `workflow_definitions`, persists the import result and consumes the inspection. `internal/handler` maps the approved REST contract to these services and app routing/RBAC remains the enforcement boundary. + +**Tech Stack:** Go, Gin, SQLite via `github.com/mattn/go-sqlite3`, archive/zip, SHA-256, existing Eino `ValidateGraphJSON`. + +## Global Constraints + +- Backend only: do not modify `web/templates`, `web/static`, i18n, or any other frontend file. +- Support exactly one `workflows/*.json` item; never execute package contents. +- Request ZIP maximum is 10 MiB and extracted total maximum is 20 MiB. +- Keep `workflow_definitions.version` local: create/rename is 1 and overwrite is the existing local version plus one. +- Use `workflow:read` only for export, `workflow:write` for every inspection/import route, and require existing RBAC `all` scope for package mutations. +- Preserve existing CRUD, validate, dry-run and run API response formats. + +--- + +### Task 1: Package format, canonical hashes and deterministic export + +**Files:** +- Create: `internal/workflow/package/manifest.go` +- Create: `internal/workflow/package/exporter.go` +- Test: `internal/workflow/package/exporter_test.go` + +**Interfaces:** +- Produces: `Export(database.WorkflowDefinition) ([]byte, ExportMetadata, error)`, `InspectArchive(context.Context, []byte) (*InspectionResult, error)`, and typed package errors exposing `Code`, safe `Message`, and safe `Details`. +- Consumes: `database.WorkflowDefinition` and the existing graph JSON fields only. + +- [ ] **Step 1: Write failing package tests.** Cover two identical exports producing byte-identical ZIPs, lower-case `sha256:` hashes, `manifest.json`/`checksums.sha256`/one workflow entry, and source revision equal to the source workflow's local version. +- [ ] **Step 2: Run the package test.** Run `go test ./internal/workflow/package -run 'TestExport'`; expected failure is missing package export symbols. +- [ ] **Step 3: Implement canonical JSON and exporter.** Canonicalize JSON with `Decoder.UseNumber`, hash canonical graph JSON and canonical item JSON, derive a stable package id and fixed ZIP metadata, then write entries in lexical order. +- [ ] **Step 4: Run the package test.** Run `go test ./internal/workflow/package -run 'TestExport'`; expected result is PASS. + +### Task 2: Safe package inspection and validation + +**Files:** +- Create: `internal/workflow/package/inspector.go` +- Test: `internal/workflow/package/inspector_test.go` + +**Interfaces:** +- Consumes: ZIP bytes and `workflow.ValidateGraphJSON(context.Context, string)`. +- Produces: validated manifest/workflow payload, package/content/graph hashes, node/edge counts, and contract error codes without archive paths. + +- [ ] **Step 1: Write failing inspector tests.** Use an exported valid package and assert accepted parsing; add independent cases for path traversal, duplicate names, symlink entries, undeclared files, checksum mismatch, two workflow entries, extracted-size overflow, invalid manifest and invalid graph. +- [ ] **Step 2: Run the inspector test.** Run `go test ./internal/workflow/package -run 'TestInspect'`; expected failure is missing inspection implementation. +- [ ] **Step 3: Implement archive checks before parsing.** Reject non-exact paths, duplicate names, links, unexpected entries and declared/actual oversized extraction; validate checksums and Manifest 1.0; require exactly one declared workflow entry; then reuse `ValidateGraphJSON`. +- [ ] **Step 4: Run the inspector test.** Run `go test ./internal/workflow/package -run 'TestInspect'`; expected result is PASS. + +### Task 3: SQLite package state, lifecycle, and transactional application + +**Files:** +- Modify: `internal/database/database.go` +- Create: `internal/database/workflow_package.go` +- Test: `internal/database/workflow_package_test.go` + +**Interfaces:** +- Produces: inspection create/read/expiry methods, `ApplyWorkflowPackageImport` and `PurgeWorkflowPackageLifecycle(time.Time)`. +- Consumes: primitive database request structs carrying inspected payload and immutable conflict snapshot; no browser-supplied workflow JSON. + +- [ ] **Step 1: Write failing DB tests.** Assert migration tables/indexes exist; inspection expiry transitions to `expired`; first successful application consumes inspection; same actor/key/same hash returns stored import; same key/different hash rejects; changed target snapshot rejects; overwrite increments local version; create and rename start at version 1; rollback leaves workflow/import/inspection unchanged on failure. +- [ ] **Step 2: Run the DB test.** Run `go test ./internal/database -run 'TestWorkflowPackage'`; expected failure is missing migration and methods. +- [ ] **Step 3: Add exact DDL and transactional repository method.** Add the two contract tables and indexes to `initTables`; in one `BEGIN` transaction recheck owner/status/expiry/idempotency/snapshot, apply the allowed action, insert import row, mark inspection consumed, and commit. Add 24-hour expired-inspection and 90-day import cleanup. +- [ ] **Step 4: Run the DB test.** Run `go test ./internal/database -run 'TestWorkflowPackage'`; expected result is PASS. + +### Task 4: Import orchestration, HTTP handlers, audit and routes + +**Files:** +- Create: `internal/workflow/package/importer.go` +- Create: `internal/handler/workflow_package.go` +- Modify: `internal/handler/workflow.go` +- Modify: `internal/app/app.go` +- Modify: `internal/security/rbac_middleware.go` +- Test: `internal/handler/workflow_package_test.go` + +**Interfaces:** +- Consumes: authenticated `security.Session`, `Idempotency-Key`, multipart `file`, database package state, and typed package errors. +- Produces: contract response envelopes, `application/zip` export headers, cache invalidation after committed writes, and audit events in category `workflow_package`. + +- [ ] **Step 1: Write failing handler/RBAC tests.** Cover 403 mapping for read/write permissions, 10 MiB file limit, export headers/404, creator-only inspection/import reads, 201 first apply/200 idempotent replay, contract error body/status, and the existing validate/dry-run/runs routes still resolving. +- [ ] **Step 2: Run the handler test.** Run `go test ./internal/handler -run 'TestWorkflowPackage'`; expected failure is missing routes/handlers. +- [ ] **Step 3: Implement service and handlers.** Limit upload bytes before multipart parsing; persist only validated payload; perform request-hash and action validation in importer; map package errors to approved statuses; invalidate the compiled cache only after commit; record export/inspect/import success and failure audits. +- [ ] **Step 4: Register and authorize routes.** Register exact paths `GET /workflows/:id/package`, `POST|GET /workflow-package-inspections`, and `POST|GET /workflow-package-imports`; make the route mapper explicit and treat inspection/import POSTs as process-global workflow mutations. +- [ ] **Step 5: Run focused handler tests.** Run `go test ./internal/handler -run 'TestWorkflowPackage'`; expected result is PASS. + +### Task 5: Lifecycle wiring and final verification + +**Files:** +- Modify: `internal/app/app.go` +- Test: the tests from Tasks 1-4 + +- [ ] **Step 1: Write the failing lifecycle wiring test or startup-level assertion.** Assert startup invokes package lifecycle cleanup and that the retention loop has no workflow-definition side effect. +- [ ] **Step 2: Implement startup cleanup/loop.** Invoke `PurgeWorkflowPackageLifecycle(time.Now().UTC())` at startup and start an hourly package lifecycle loop after the database is ready. +- [ ] **Step 3: Run format and focused verification.** Run `gofmt -w` only on changed Go files, `go test ./internal/workflow/package`, `go test ./internal/database -run 'TestWorkflowPackage'`, `go test ./internal/handler -run 'TestWorkflowPackage'`, and `git diff --check`. +- [ ] **Step 4: Run compatible regression verification.** Run `go test ./internal/database ./internal/handler ./internal/workflow` in an environment with the required C compiler, then inspect `git diff --check` and `git status --short` before committing. +- [ ] **Step 5: Commit verified files.** Run `git add internal/workflow/package internal/database/database.go internal/database/workflow_package.go internal/handler/workflow.go internal/handler/workflow_package.go internal/security/rbac_middleware.go internal/app/app.go docs/superpowers/plans/2026-07-13-local-workflow-package-mvp.md` followed by `git commit -m "feat: add local workflow package mvp"`. diff --git a/docs/superpowers/specs/2026-07-13-local-workflow-package-api-data-contract.md b/docs/superpowers/specs/2026-07-13-local-workflow-package-api-data-contract.md new file mode 100644 index 00000000..19027c65 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-local-workflow-package-api-data-contract.md @@ -0,0 +1,334 @@ +# 本地图编排策略包 MVP:API 与数据模型契约 v1 + +> 本文是 [本地图编排策略包 MVP 设计](2026-07-13-local-workflow-package-mvp-design.md) 的实现前契约。前端与后端以本文的路径、字段、枚举、状态码和错误码为准;未经版本升级不得改变既有字段语义。 + +## 1. 范围与不变式 + +- 仅支持一个工作流的本地 `.csapkg.zip` 包。 +- 仅处理 `workflow_definitions`;不导入 Role、Skill、MCP 配置、运行记录或任何可执行文件。 +- 导入固定为“上传预检”和“确认应用”两步。预检不修改 `workflow_definitions`。 +- 现有工作流 CRUD、`/validate`、`/dry-run`、运行 API 和 `workflow_definitions` 表结构保持兼容。 +- 已有 `workflow_definitions.version` 始终是目标实例本地修订号:新建导入从 `1` 开始;覆盖导入由现有本地版本递增;包内 `source_revision` 只用于展示和审计。 + +## 2. 统一约定 + +### 2.1 认证与权限 + +所有 API 均位于现有受保护的 `/api` 路由组。 + +| 接口 | 所需权限 | +|---|---| +| 导出包 | `workflow:read` | +| 创建或读取预检 | `workflow:write` | +| 应用或读取导入结果 | `workflow:write` | + +预检会保存短期、已验证的工作流载荷,故不把它降级为只读权限。`created_by` / `actor_user_id` 取当前已认证会话的 `UserID`。 + +RBAC 路由映射必须显式新增:`GET /workflows/:id/package` 映射 `workflow:read`;`/workflow-package-inspections` 与 `/workflow-package-imports` 的所有 MVP 路由映射 `workflow:write`。它们与既有工作流定义同属全局资产,写操作仅允许现有 RBAC 的 `all` 资源范围。 + +### 2.2 错误响应 + +新接口统一使用如下错误响应;不改变旧工作流 API 的 `{"error":"..."}` 兼容格式。 + +```json +{ + "error": { + "code": "WFPKG_ID_CONFLICT", + "message": "目标实例已存在同 ID 工作流,请选择处理策略", + "details": { + "workflow_id": "web-src-hunting" + } + } +} +``` + +`details` 仅包含可安全展示的结构化信息,不返回 Zip 路径、服务端文件路径、Token 或内部堆栈。 + +### 2.3 时间与哈希 + +- 所有时间字段使用 RFC 3339 UTC 字符串。 +- 哈希固定为小写十六进制 `sha256:<64-hex>`。 +- `graph_hash` 是对规范化 `graph_json` 的 SHA-256;`content_hash` 是工作流包项的 SHA-256。 + +## 3. REST API + +### 3.1 导出单工作流包 + +```http +GET /api/workflows/{id}/package +Accept: application/zip +``` + +语义:从当前 `workflow_definitions` 行生成 `.csapkg.zip`。只读、幂等、无确认、无数据库写入。 + +成功响应: + +```http +200 OK +Content-Type: application/zip +Content-Disposition: attachment; filename="web-src-hunting.csapkg.zip" +ETag: "sha256:3f..." +X-Workflow-Package-SHA256: sha256:3f... +``` + +失败:`404 WFPKG_WORKFLOW_NOT_FOUND`、`403`、`500 WFPKG_EXPORT_FAILED`。 + +### 3.2 创建预检 + +```http +POST /api/workflow-package-inspections +Content-Type: multipart/form-data + +file=@web-src-hunting.csapkg.zip;type=application/zip +``` + +限制:请求体最大 10 MiB;Zip 解压总量最大 20 MiB;只允许 `manifest.json`、`checksums.sha256` 和一个 `workflows/*.json`。同一包不得有重复条目、软链接、路径穿越或未声明文件。 + +成功响应:`201 Created`。 + +```json +{ + "inspection": { + "id": "wpi_01JQ2K6G7K8W2C1E3R4T5Y6U7I", + "status": "ready", + "expires_at": "2026-07-13T09:30:00Z", + "package": { + "package_format": "cyberstrikeai.workflow-package", + "format_version": "1.0", + "package_id": "pkg_01JWEBHUNT", + "package_hash": "sha256:af..." + }, + "workflow": { + "source_id": "web-src-hunting", + "name": "Web SRC 猎洞", + "description": "面向 SRC Web 资产的侦察与漏洞候选流程", + "source_revision": 18, + "enabled": true, + "content_hash": "sha256:51...", + "graph_hash": "sha256:a9...", + "node_count": 10, + "edge_count": 11 + }, + "conflict": { + "state": "id_conflict", + "local_workflow": { + "id": "web-src-hunting", + "version": 12, + "content_hash": "sha256:42...", + "graph_hash": "sha256:17..." + } + }, + "warnings": [] + } +} +``` + +`conflict.state` 固定枚举: + +| 值 | 含义 | +|---|---| +| `none` | 目标不存在,可 `create`。 | +| `identical` | 目标同 ID 且 `content_hash` 相同。 | +| `id_conflict` | 目标同 ID,但内容不同。 | + +无效包不创建 inspection,直接返回 `422`。常用错误码:`WFPKG_FILE_REQUIRED`、`WFPKG_FILE_TOO_LARGE`、`WFPKG_INVALID_ARCHIVE`、`WFPKG_UNSUPPORTED_FORMAT`、`WFPKG_INVALID_MANIFEST`、`WFPKG_CHECKSUM_MISMATCH`、`WFPKG_MULTIPLE_WORKFLOWS`、`WFPKG_WORKFLOW_INVALID`。 + +### 3.3 读取预检 + +```http +GET /api/workflow-package-inspections/{inspectionId} +``` + +用于前端刷新页面后恢复预检状态。仅 inspection 创建者可读取;不存在返回 `404 WFPKG_INSPECTION_NOT_FOUND`,已过期返回 `409 WFPKG_INSPECTION_EXPIRED`。 + +### 3.4 应用导入 + +```http +POST /api/workflow-package-imports +Content-Type: application/json +Idempotency-Key: 4b75a1eb-7ed1-4eb1-a074-389dba3d4d7b +``` + +```json +{ + "inspection_id": "wpi_01JQ2K6G7K8W2C1E3R4T5Y6U7I", + "resolution": { + "action": "overwrite", + "new_workflow_id": "" + }, + "confirm_overwrite": true +} +``` + +字段规则: + +| 字段 | 规则 | +|---|---| +| `inspection_id` | 必填;必须是当前用户创建、状态为 `ready` 且未过期的 inspection。 | +| `resolution.action` | `create`、`keep_existing`、`overwrite`、`rename` 之一。 | +| `resolution.new_workflow_id` | 仅 `rename` 必填;去除首尾空格后 1–128 字符,不得包含控制字符。其他 action 必须传空字符串。 | +| `confirm_overwrite` | 仅 `overwrite` 时必须为 `true`。 | +| `Idempotency-Key` | 必填 UUID;同一用户、同一 key、同一请求返回原结果;同 key 不同请求返回冲突。 | + +`request_hash` 固定为下列字段按键名升序、无空白序列化后的 SHA-256:`inspection_id`、`resolution.action`、`resolution.new_workflow_id`、`confirm_overwrite`。后端不得将 `Idempotency-Key` 自身计入该 hash。 + +动作与 inspection 状态的合法组合: + +| `conflict.state` | 合法 action | 结果 | +|---|---|---| +| `none` | `create` | 新建目标工作流。 | +| `identical` | `keep_existing` | 返回 `skipped_identical`,不修改工作流。 | +| `id_conflict` | `keep_existing` | 返回 `kept_existing`,不修改工作流。 | +| `id_conflict` | `overwrite` | 完整替换 name、description、graph_json、enabled;本地 version 递增。 | +| `id_conflict` | `rename` | 以 `new_workflow_id` 新建副本,version 为 1。 | + +后端在应用事务开始前必须重新读取目标工作流并比较 inspection 中记录的冲突快照;若本地内容在预检后变化,返回 `409 WFPKG_CONFLICT_CHANGED`,前端必须重新预检。 + +首次应用成功返回 `201 Created`: + +```json +{ + "import": { + "id": "wpii_01JQ2M93S2PH0WY8X7B4F8R9QG", + "inspection_id": "wpi_01JQ2K6G7K8W2C1E3R4T5Y6U7I", + "status": "succeeded", + "result": "overwritten", + "action": "overwrite", + "source_workflow_id": "web-src-hunting", + "target_workflow_id": "web-src-hunting", + "workflow": { + "id": "web-src-hunting", + "version": 13, + "content_hash": "sha256:51...", + "graph_hash": "sha256:a9..." + }, + "applied_at": "2026-07-13T09:05:00Z" + } +} +``` + +同一幂等键重试返回 `200 OK` 和完全相同的 `import` 对象。成功写入后必须调用 `InvalidateCompiledCache(workflowID)`。 + +错误码: + +| HTTP | code | 触发条件 | +|---:|---|---| +| 400 | `WFPKG_IDEMPOTENCY_KEY_REQUIRED` | 缺少或非 UUID 幂等键。 | +| 404 | `WFPKG_INSPECTION_NOT_FOUND` | inspection 不存在或不属于当前用户。 | +| 409 | `WFPKG_INSPECTION_EXPIRED` | inspection 已过期。 | +| 409 | `WFPKG_INSPECTION_CONSUMED` | inspection 已被其他幂等键成功应用。 | +| 409 | `WFPKG_IDEMPOTENCY_KEY_REUSED` | 同 key 的请求体不同。 | +| 409 | `WFPKG_ID_CONFLICT` | action 与预检冲突状态不匹配。 | +| 409 | `WFPKG_OVERWRITE_CONFIRMATION_REQUIRED` | overwrite 未确认。 | +| 409 | `WFPKG_CONFLICT_CHANGED` | 预检后本地工作流已改变。 | +| 422 | `WFPKG_INVALID_ACTION` | action 不在枚举中,或 action 与字段组合不合法。 | +| 422 | `WFPKG_INVALID_RENAME_ID` | rename ID 为空、含控制字符或已存在。 | +| 500 | `WFPKG_IMPORT_FAILED` | 事务失败;不修改目标工作流。 | + +### 3.5 查询导入结果 + +```http +GET /api/workflow-package-imports/{importId} +``` + +仅导入创建者可读取。响应为 3.4 中的 `import` 对象。该接口不提供列表;MVP 的历史审计通过现有审计日志页面查看。 + +## 4. SQLite 数据模型 + +`workflow_definitions` 不增列、不改语义。新增两张表;DDL 即为迁移目标。 + +```sql +CREATE TABLE IF NOT EXISTS workflow_package_inspections ( + id TEXT PRIMARY KEY, + package_hash TEXT NOT NULL, + manifest_json TEXT NOT NULL, + workflow_payload_json TEXT NOT NULL, + inspection_json TEXT NOT NULL, + source_workflow_id TEXT NOT NULL, + source_revision INTEGER NOT NULL, + source_content_hash TEXT NOT NULL, + source_graph_hash TEXT NOT NULL, + local_conflict_state TEXT NOT NULL + CHECK (local_conflict_state IN ('none', 'identical', 'id_conflict')), + local_workflow_id TEXT, + local_content_hash TEXT, + local_graph_hash TEXT, + created_by TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ready' + CHECK (status IN ('ready', 'consumed', 'expired')), + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + consumed_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_workflow_package_inspections_creator_expiry + ON workflow_package_inspections(created_by, expires_at); + +CREATE TABLE IF NOT EXISTS workflow_package_imports ( + id TEXT PRIMARY KEY, + inspection_id TEXT NOT NULL, + request_hash TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + actor_user_id TEXT NOT NULL, + action TEXT NOT NULL + CHECK (action IN ('create', 'keep_existing', 'overwrite', 'rename')), + source_workflow_id TEXT NOT NULL, + target_workflow_id TEXT NOT NULL, + resulting_workflow_id TEXT, + result TEXT NOT NULL + CHECK (result IN ('created', 'overwritten', 'renamed', 'kept_existing', 'skipped_identical', 'failed')), + error_code TEXT, + error_message TEXT, + created_at DATETIME NOT NULL, + applied_at DATETIME, + FOREIGN KEY (inspection_id) REFERENCES workflow_package_inspections(id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_actor_key + ON workflow_package_imports(actor_user_id, idempotency_key); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_inspection_success + ON workflow_package_imports(inspection_id) + WHERE result IN ('created', 'overwritten', 'renamed', 'kept_existing', 'skipped_identical'); +``` + +### 4.1 表职责与生命周期 + +| 表 | 职责 | 保留规则 | +|---|---|---| +| `workflow_package_inspections` | 保存已验证的 Manifest、单工作流载荷、冲突快照和前端恢复所需摘要;不保存原始 Zip。 | `expires_at` 为创建后 30 分钟;到期改为 `expired`;清理任务可在 24 小时后删除。 | +| `workflow_package_imports` | 导入结果、幂等键和应用记录。 | 保留 90 天;删除不影响既有审计日志。 | + +inspection 创建时写入 `workflow_payload_json`,应用时只读取该已验证载荷,不信任浏览器重新提交的工作流内容。`inspection_json` 是 3.2 成功响应中的安全摘要快照。 + +### 4.2 导入事务 + +导入应用必须在一个 SQLite 事务中完成以下操作: + +1. 校验 inspection 所属用户、状态、有效期与幂等键。 +2. 再次读取目标 `workflow_definitions`,验证冲突快照未变化。 +3. 按 action 新建、覆盖、重命名或保持现有工作流。 +4. 新增 `workflow_package_imports` 成功行,并把 inspection 改为 `consumed`。 +5. 提交事务;提交后失效工作流编译缓存并写审计日志。 + +任一步失败必须回滚工作流、导入行和 inspection 状态。`workflow_package_imports.result='failed'` 仅在能安全独立记录失败时写入,绝不替代事务回滚。 + +## 5. 审计契约 + +复用现有 `audit_logs`,不在包表中复制审计全文: + +| 事件 | category | action | resource | +|---|---|---|---| +| 导出成功 | `workflow_package` | `export` | `workflow/{id}` | +| 预检成功 | `workflow_package` | `inspect` | `inspection/{id}` | +| 预检失败 | `workflow_package` | `inspect` | 无资源 ID;detail 仅含错误码与包 hash | +| 应用成功 | `workflow_package` | `import` | `workflow/{resulting_workflow_id}` | +| 应用失败 | `workflow_package` | `import` | `inspection/{id}` | + +## 6. 前后端并行边界 + +前端可依据本文直接完成:下载按钮、文件上传、预检结果页、冲突动作选择、`confirm_overwrite` 二次确认、导入结果页和错误码国际化。 + +后端可依据本文直接完成:路由、Handler、包解析服务、SQLite 迁移、事务、RBAC 映射、审计和单元/集成测试。 + +前端不得自行解析 Zip、计算最终冲突结论或直接提交工作流 JSON;后端是 Manifest、哈希、图校验、冲突复查和导入结果的唯一权威。 diff --git a/docs/superpowers/specs/2026-07-13-local-workflow-package-mvp-design.md b/docs/superpowers/specs/2026-07-13-local-workflow-package-mvp-design.md new file mode 100644 index 00000000..2e751357 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-local-workflow-package-mvp-design.md @@ -0,0 +1,134 @@ +# 本地图编排策略包 MVP 设计 + +## 决策摘要 + +本期只交付本地图编排策略管理,不接入公共市场、远程仓库、发布上传、账号、评分或订阅能力。目标是先建立稳定的工作流包格式和安全导入闭环;未来市场仅复用该包格式和本地安装器。 + +## 目标与非目标 + +目标:用户可将单个工作流导出为可离线传输、可审查的包,并在另一实例中完成预检后显式导入。 + +本期非目标: + +- 批量导出、批量导入、按标签或角色筛选。 +- 角色、Skill、工具元数据的实际导出或安装。 +- 远程仓库配置、策略市场、下载、上传和发布者身份。 +- 自动合并、三方 diff、跨实例 SemVer 升级、降级和回滚。 +- 工作流运行记录、会话、项目数据、MCP 密钥或任何可执行载荷。 + +## 当前基础 + +- 工作流保存在 SQLite 的 `workflow_definitions`,包含 `id`、`name`、`description`、整型 `version`、`graph_json`、`enabled`。 +- 保存前已有严格的 `ValidateGraphJSON` 校验;MVP 导入必须复用它。 +- 当前工作流 CRUD、`/validate`、`/dry-run` 和运行 API 不改变。 +- 角色和 Skill 分别存放于 `roles/`、`skills/`,本期不写入这两个目录。 + +## 用户流程 + +### 导出 + +1. 用户在图编排详情页选择“导出”。 +2. 系统读取单个工作流定义,生成 `.csapkg.zip`。 +3. 用户下载包并可解压审查 JSON 与 Manifest。 + +### 导入 + +1. 用户在图编排列表页选择“导入本地包”。 +2. 系统上传并解析 Zip,但不写入数据库。 +3. 系统检查包结构、文件哈希、工作流 JSON,并调用 `ValidateGraphJSON`。 +4. 用户查看工作流名称、ID、节点/边数量、`graph_json` hash 和冲突结果。 +5. 用户确认“创建”或在冲突时选择“保留本地 / 覆盖 / 另存为新 ID”。 +6. 系统写入工作流、失效编译缓存、写入审计日志并返回结果。 + +导入始终为两步:预检不会写入;仅确认后的应用步骤会改变本地工作流。缺少本期未处理的工具依赖时可显示提示,但不得阻止仅保存定义的导入。 + +## 包格式 + +文件扩展名为 `.csapkg.zip`,解压后保持人类可读: + +```text +web-src-hunting-1.0.0.csapkg.zip +├─ manifest.json +├─ checksums.sha256 +└─ workflows/ + └─ web-src-hunting.json +``` + +`manifest.json` 示例: + +```json +{ + "package_format": "cyberstrikeai.workflow-package", + "format_version": "1.0", + "package_id": "pkg_01JWEBHUNT", + "created_at": "2026-07-13T10:00:00Z", + "items": [ + { + "type": "workflow", + "path": "workflows/web-src-hunting.json", + "source_id": "web-src-hunting", + "source_revision": 18, + "content_hash": "sha256:...", + "graph_hash": "sha256:..." + } + ] +} +``` + +`workflows/*.json` 保留现有工作流字段。现有整型 `version` 继续作为本地修订号;MVP 不引入 SemVer,也不将版本解释为跨实例升级语义。 + +## 冲突规则 + +| 目标状态 | 默认行为 | 可选动作 | +|---|---|---| +| 本地不存在同 ID | 创建 | 无 | +| 本地存在同 ID | 保留本地并报告冲突 | 覆盖、另存为新 ID、取消 | +| 包内容 hash 与本地一致 | 跳过,视为幂等成功 | 无 | + +覆盖是破坏性操作,必须二次确认。另存为新 ID 时仅修改导入副本 ID,不修改任何角色绑定。 + +## 后端边界 + +新增 `internal/workflow/package` 包: + +- `manifest.go`:包格式、JSON 解析和版本兼容。 +- `exporter.go`:从 `workflow_definitions` 生成 Zip。 +- `inspector.go`:安全解压、哈希校验、结构校验与 `ValidateGraphJSON` 复用。 +- `importer.go`:冲突策略、数据库写入和编译缓存失效。 + +建议新增 API: + +| API | 语义 | 写入 / 确认 | +|---|---|---| +| `POST /api/workflow-packages/exports` | 生成并下载单工作流包 | 无写入;无需确认 | +| `POST /api/workflow-packages/inspections` | 上传并预检包 | 无写入;无需确认 | +| `POST /api/workflow-package-imports` | 创建并应用导入计划 | 有写入;覆盖时需确认 | + +现有 API 不变。权限建议沿用 `workflow:read` 用于导出,`workflow:write` 用于导入;若后续需要细粒度授权,再拆出 `workflow:export`、`workflow:import`。 + +## 安全与审计 + +- 仅允许 Manifest 与声明的 JSON 文本;拒绝 Zip 路径穿越、重复条目、软链接、超大解压和未知文件。 +- 校验每个包项 SHA-256。 +- 不导出或导入密钥、Token、MCP 连接配置、运行记录和可执行文件。 +- 预检与实际导入分别写审计日志;应用记录包 hash、工作流 ID、策略和结果。 + +## 前端范围 + +- 图编排列表页:增加“导入本地包”。 +- 图编排详情页:增加“导出”。 +- 导入 Modal:上传、预检结果、冲突策略和确认应用。 +- `workflows.js` 负责调用新 API;不增加策略市场、远程仓库或发布 UI。 + +## 验收与测试 + +- 导出 `web-src-hunting` 后可解压并审查 Manifest 与完整工作流 JSON。 +- 导入包在确认前不改变数据库。 +- 合法图可创建;非法 DAG 或节点参数由 `ValidateGraphJSON` 拒绝。 +- 同 ID 默认不覆盖;覆盖须确认;另存生成新 ID。 +- 包 hash 不匹配、路径穿越、未知文件、超限文件均被拒绝。 +- 成功导入后工作流可由现有 GET API 读取,且编译缓存已失效。 + +## 后续扩展边界 + +v1 可增加批量导入导出、Role/Skill 可选项、工具依赖展示与导入历史。v2 可基于同一 `.csapkg.zip` 增加语义标识、SemVer、升级 diff、三方合并与回滚。公共策略市场、远程仓库和发布上传属于 v3 之后的独立子项目,不进入本期实现。 diff --git a/internal/app/app.go b/internal/app/app.go index aa56adb3..3e3215f6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -120,6 +120,18 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error audit.RegisterConversationCreateHook(auditSvc) auditSvc.PurgeExpired() audit.StartRetentionLoop(auditSvc, log.Logger) + if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil { + log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err)) + } + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for range ticker.C { + if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil { + log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err)) + } + } + }() monitorRetention := monitor.NewService(db, cfg, log.Logger) monitorRetention.PurgeExpired() @@ -1314,6 +1326,11 @@ func setupRoutes( protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun) protected.POST("/workflows/validate", workflowHandler.Validate) protected.POST("/workflows/dry-run", workflowHandler.DryRun) + protected.GET("/workflows/:id/package", workflowHandler.ExportPackage) + protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection) + protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection) + protected.POST("/workflow-package-imports", workflowHandler.ApplyPackageImport) + protected.GET("/workflow-package-imports/:importId", workflowHandler.GetPackageImport) protected.GET("/workflows", workflowHandler.List) protected.GET("/workflows/:id", workflowHandler.Get) protected.POST("/workflows", workflowHandler.Create) diff --git a/internal/database/database.go b/internal/database/database.go index 57fa34fc..d80c58fc 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -667,6 +667,28 @@ func (db *DB) initTables() error { FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE );` + createWorkflowPackageInspectionsTable := ` + CREATE TABLE IF NOT EXISTS workflow_package_inspections ( + id TEXT PRIMARY KEY, package_hash TEXT NOT NULL, manifest_json TEXT NOT NULL, + workflow_payload_json TEXT NOT NULL, inspection_json TEXT NOT NULL, + source_workflow_id TEXT NOT NULL, source_revision INTEGER NOT NULL, + source_content_hash TEXT NOT NULL, source_graph_hash TEXT NOT NULL, + local_conflict_state TEXT NOT NULL CHECK (local_conflict_state IN ('none','identical','id_conflict')), + local_workflow_id TEXT, local_content_hash TEXT, local_graph_hash TEXT, + created_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('ready','consumed','expired')), + created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, consumed_at DATETIME + );` + createWorkflowPackageImportsTable := ` + CREATE TABLE IF NOT EXISTS workflow_package_imports ( + id TEXT PRIMARY KEY, inspection_id TEXT NOT NULL, request_hash TEXT NOT NULL, + idempotency_key TEXT NOT NULL, actor_user_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('create','keep_existing','overwrite','rename')), + source_workflow_id TEXT NOT NULL, target_workflow_id TEXT NOT NULL, resulting_workflow_id TEXT, + result TEXT NOT NULL CHECK (result IN ('created','overwritten','renamed','kept_existing','skipped_identical','failed')), + error_code TEXT, error_message TEXT, created_at DATETIME NOT NULL, applied_at DATETIME, + FOREIGN KEY (inspection_id) REFERENCES workflow_package_inspections(id) + );` + // 创建索引 createIndexes := ` CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id); @@ -731,6 +753,9 @@ func (db *DB) initTables() error { CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON workflow_runs(conversation_id); CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status); CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run ON workflow_node_runs(run_id); + CREATE INDEX IF NOT EXISTS idx_workflow_package_inspections_creator_expiry ON workflow_package_inspections(created_by, expires_at); + CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_actor_key ON workflow_package_imports(actor_user_id, idempotency_key); + CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_inspection_success ON workflow_package_imports(inspection_id) WHERE result IN ('created','overwritten','renamed','kept_existing','skipped_identical'); ` if _, err := db.Exec(createConversationsTable); err != nil { @@ -830,9 +855,11 @@ func (db *DB) initTables() error { } for tableName, ddl := range map[string]string{ - "workflow_definitions": createWorkflowDefinitionsTable, - "workflow_runs": createWorkflowRunsTable, - "workflow_node_runs": createWorkflowNodeRunsTable, + "workflow_definitions": createWorkflowDefinitionsTable, + "workflow_runs": createWorkflowRunsTable, + "workflow_node_runs": createWorkflowNodeRunsTable, + "workflow_package_inspections": createWorkflowPackageInspectionsTable, + "workflow_package_imports": createWorkflowPackageImportsTable, } { if _, err := db.Exec(ddl); err != nil { return fmt.Errorf("创建%s表失败: %w", tableName, err) diff --git a/internal/database/workflow_package.go b/internal/database/workflow_package.go new file mode 100644 index 00000000..890c838b --- /dev/null +++ b/internal/database/workflow_package.go @@ -0,0 +1,286 @@ +package database + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + "unicode" + + "github.com/google/uuid" +) + +type WorkflowPackageInspection struct { + ID, PackageHash, ManifestJSON, WorkflowPayloadJSON, InspectionJSON string + SourceWorkflowID, SourceContentHash, SourceGraphHash string + SourceRevision int + LocalConflictState, LocalWorkflowID, LocalContentHash, LocalGraphHash string + CreatedBy, Status string + CreatedAt, ExpiresAt time.Time + ConsumedAt *time.Time +} + +type WorkflowPackageImport struct { + ID, InspectionID, RequestHash, IdempotencyKey, ActorUserID string + Action, SourceWorkflowID, TargetWorkflowID, ResultingWorkflowID string + Result, ErrorCode, ErrorMessage string + CreatedAt time.Time + AppliedAt *time.Time +} + +type WorkflowPackageApplyRequest struct { + InspectionID, RequestHash, IdempotencyKey, ActorUserID, Action, NewWorkflowID string + ConfirmOverwrite bool +} + +type WorkflowPackageStoreError struct{ Code, Message string } + +func (e *WorkflowPackageStoreError) Error() string { return e.Code + ": " + e.Message } +func workflowPackageStoreError(code, message string) error { + return &WorkflowPackageStoreError{code, message} +} + +func (db *DB) CreateWorkflowPackageInspection(v *WorkflowPackageInspection) error { + if v == nil || strings.TrimSpace(v.ID) == "" || strings.TrimSpace(v.CreatedBy) == "" { + return fmt.Errorf("workflow package inspection is incomplete") + } + if v.CreatedAt.IsZero() { + v.CreatedAt = time.Now().UTC() + } + if v.ExpiresAt.IsZero() { + v.ExpiresAt = v.CreatedAt.Add(30 * time.Minute) + } + _, err := db.Exec(`INSERT INTO workflow_package_inspections (id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,local_workflow_id,local_content_hash,local_graph_hash,created_by,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, v.ID, v.PackageHash, v.ManifestJSON, v.WorkflowPayloadJSON, v.InspectionJSON, v.SourceWorkflowID, v.SourceRevision, v.SourceContentHash, v.SourceGraphHash, v.LocalConflictState, nullString(v.LocalWorkflowID), nullString(v.LocalContentHash), nullString(v.LocalGraphHash), v.CreatedBy, "ready", v.CreatedAt.UTC(), v.ExpiresAt.UTC()) + return err +} + +func (db *DB) GetWorkflowPackageInspection(id, actor string) (*WorkflowPackageInspection, error) { + now := time.Now().UTC() + _, _ = db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at <= ?`, now) + row, err := scanWorkflowPackageInspection(db.QueryRow(`SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, strings.TrimSpace(id), strings.TrimSpace(actor))) + if err == sql.ErrNoRows { + return nil, nil + } + return row, err +} + +func scanWorkflowPackageInspection(s interface{ Scan(...any) error }) (*WorkflowPackageInspection, error) { + var v WorkflowPackageInspection + var consumed sql.NullTime + err := s.Scan(&v.ID, &v.PackageHash, &v.ManifestJSON, &v.WorkflowPayloadJSON, &v.InspectionJSON, &v.SourceWorkflowID, &v.SourceRevision, &v.SourceContentHash, &v.SourceGraphHash, &v.LocalConflictState, &v.LocalWorkflowID, &v.LocalContentHash, &v.LocalGraphHash, &v.CreatedBy, &v.Status, &v.CreatedAt, &v.ExpiresAt, &consumed) + if consumed.Valid { + t := consumed.Time + v.ConsumedAt = &t + } + return &v, err +} + +func (db *DB) GetWorkflowPackageImport(id, actor string) (*WorkflowPackageImport, error) { + v, err := scanWorkflowPackageImport(db.QueryRow(`SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE id=? AND actor_user_id=?`, strings.TrimSpace(id), strings.TrimSpace(actor))) + if err == sql.ErrNoRows { + return nil, nil + } + return v, err +} +func scanWorkflowPackageImport(s interface{ Scan(...any) error }) (*WorkflowPackageImport, error) { + var v WorkflowPackageImport + var applied sql.NullTime + err := s.Scan(&v.ID, &v.InspectionID, &v.RequestHash, &v.IdempotencyKey, &v.ActorUserID, &v.Action, &v.SourceWorkflowID, &v.TargetWorkflowID, &v.ResultingWorkflowID, &v.Result, &v.ErrorCode, &v.ErrorMessage, &v.CreatedAt, &applied) + if applied.Valid { + t := applied.Time + v.AppliedAt = &t + } + return &v, err +} + +func (db *DB) ApplyWorkflowPackageImport(ctx context.Context, req WorkflowPackageApplyRequest) (*WorkflowPackageImport, bool, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, false, err + } + defer tx.Rollback() + var existingHash string + previous, prevErr := scanWorkflowPackageImport(tx.QueryRowContext(ctx, `SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE actor_user_id=? AND idempotency_key=?`, req.ActorUserID, req.IdempotencyKey)) + if prevErr == nil { + existingHash = previous.RequestHash + if existingHash != req.RequestHash { + return nil, false, workflowPackageStoreError("WFPKG_IDEMPOTENCY_KEY_REUSED", "幂等键已用于其他请求") + } + return previous, true, nil + } + if prevErr != sql.ErrNoRows { + return nil, false, prevErr + } + inspection, err := scanWorkflowPackageInspection(tx.QueryRowContext(ctx, `SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, req.InspectionID, req.ActorUserID)) + if err == sql.ErrNoRows { + return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_NOT_FOUND", "预检不存在") + } + if err != nil { + return nil, false, err + } + now := time.Now().UTC() + if !inspection.ExpiresAt.After(now) || inspection.Status == "expired" { + _, _ = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='expired' WHERE id=?`, inspection.ID) + return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_EXPIRED", "预检已过期") + } + if inspection.Status != "ready" { + return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_CONSUMED", "预检已被使用") + } + var payload struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + GraphJSON string `json:"graph_json"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal([]byte(inspection.WorkflowPayloadJSON), &payload); err != nil { + return nil, false, fmt.Errorf("decode inspection payload: %w", err) + } + targetID := inspection.SourceWorkflowID + if req.Action == "rename" { + targetID = strings.TrimSpace(req.NewWorkflowID) + if !validWorkflowPackageID(targetID) { + return nil, false, workflowPackageStoreError("WFPKG_INVALID_RENAME_ID", "新工作流 ID 无效") + } + } + sourceCurrent, err := scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", inspection.SourceWorkflowID)) + if err == sql.ErrNoRows { + sourceCurrent = nil + } else if err != nil { + return nil, false, err + } + if err := checkWorkflowPackageSnapshot(inspection, sourceCurrent, inspection.SourceWorkflowID); err != nil { + return nil, false, err + } + current := sourceCurrent + if targetID != inspection.SourceWorkflowID { + current, err = scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", targetID)) + if err == sql.ErrNoRows { + current = nil + } else if err != nil { + return nil, false, err + } + } + result := "" + resultingID := "" + switch req.Action { + case "create": + if inspection.LocalConflictState != "none" || current != nil { + return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "目标工作流已存在") + } + result = "created" + resultingID = targetID + case "keep_existing": + if inspection.LocalConflictState == "none" { + return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许保留本地") + } + if inspection.LocalConflictState == "identical" { + result = "skipped_identical" + } else { + result = "kept_existing" + } + resultingID = targetID + case "overwrite": + if inspection.LocalConflictState != "id_conflict" { + return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许覆盖") + } + if !req.ConfirmOverwrite { + return nil, false, workflowPackageStoreError("WFPKG_OVERWRITE_CONFIRMATION_REQUIRED", "覆盖需要确认") + } + result = "overwritten" + resultingID = targetID + case "rename": + if inspection.LocalConflictState != "id_conflict" || current != nil { + return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许另存") + } + result = "renamed" + resultingID = targetID + default: + return nil, false, workflowPackageStoreError("WFPKG_INVALID_ACTION", "导入动作无效") + } + if result == "created" || result == "renamed" { + _, err = tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id,name,description,version,graph_json,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`, resultingID, payload.Name, payload.Description, 1, payload.GraphJSON, boolToInt(payload.Enabled), now, now) + } else if result == "overwritten" { + _, err = tx.ExecContext(ctx, `UPDATE workflow_definitions SET name=?,description=?,version=version+1,graph_json=?,enabled=?,updated_at=? WHERE id=?`, payload.Name, payload.Description, payload.GraphJSON, boolToInt(payload.Enabled), now, resultingID) + } + if err != nil { + return nil, false, err + } + imp := &WorkflowPackageImport{ID: "wpii_" + strings.ReplaceAll(uuid.NewString(), "-", ""), InspectionID: inspection.ID, RequestHash: req.RequestHash, IdempotencyKey: req.IdempotencyKey, ActorUserID: req.ActorUserID, Action: req.Action, SourceWorkflowID: inspection.SourceWorkflowID, TargetWorkflowID: targetID, ResultingWorkflowID: resultingID, Result: result, CreatedAt: now, AppliedAt: &now} + _, err = tx.ExecContext(ctx, `INSERT INTO workflow_package_imports (id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,resulting_workflow_id,result,created_at,applied_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, imp.ID, imp.InspectionID, imp.RequestHash, imp.IdempotencyKey, imp.ActorUserID, imp.Action, imp.SourceWorkflowID, imp.TargetWorkflowID, nullString(imp.ResultingWorkflowID), imp.Result, now, now) + if err != nil { + return nil, false, err + } + if _, err = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='consumed',consumed_at=? WHERE id=? AND status='ready'`, now, inspection.ID); err != nil { + return nil, false, err + } + if err = tx.Commit(); err != nil { + return nil, false, err + } + return imp, false, nil +} + +func checkWorkflowPackageSnapshot(i *WorkflowPackageInspection, current *WorkflowDefinition, targetID string) error { + if i.LocalConflictState == "none" { + if current != nil { + return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化") + } + return nil + } + if current == nil || current.ID != i.LocalWorkflowID || current.ID != targetID { + return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化") + } + content, graph := workflowDefinitionPackageHashes(current) + if content != i.LocalContentHash || graph != i.LocalGraphHash { + return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化") + } + return nil +} +func workflowDefinitionPackageHashes(w *WorkflowDefinition) (string, string) { + var g any + dec := json.NewDecoder(strings.NewReader(w.GraphJSON)) + dec.UseNumber() + _ = dec.Decode(&g) + graph, _ := json.Marshal(g) + payload := struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version int `json:"version"` + GraphJSON string `json:"graph_json"` + Enabled bool `json:"enabled"` + }{w.ID, w.Name, w.Description, w.Version, string(graph), w.Enabled} + b, _ := json.Marshal(payload) + return workflowPackageHash(b), workflowPackageHash(graph) +} +func workflowPackageHash(b []byte) string { + s := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(s[:]) +} +func validWorkflowPackageID(id string) bool { + if len(id) < 1 || len(id) > 128 { + return false + } + for _, r := range id { + if unicode.IsControl(r) { + return false + } + } + return true +} + +func (db *DB) PurgeWorkflowPackageLifecycle(now time.Time) error { + now = now.UTC() + if _, err := db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at<=?`, now); err != nil { + return err + } + if _, err := db.Exec(`DELETE FROM workflow_package_inspections WHERE status='expired' AND expires_at workflowpkg.MaxArchiveBytes { + writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil) + return + } + inspected, err := workflowpkg.InspectArchive(c.Request.Context(), archive, workflowrunner.ValidateGraphJSON) + if err != nil { + code := workflowpkg.ErrorCode(err) + if code == "" { + code = "WFPKG_INVALID_ARCHIVE" + } + if h.audit != nil { + h.audit.RecordFail(c, "workflow_package", "inspect", "工作流包预检失败", map[string]interface{}{"code": code, "package_hash": workflowPackageHash(archive)}) + } + writeWorkflowPackageError(c, http.StatusUnprocessableEntity, code, "工作流包预检失败", nil) + return + } + state, local, err := h.workflowPackageConflict(inspected.Document.ID, inspected.ContentHash) + if err != nil { + writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取本地工作流失败", nil) + return + } + summary := workflowPackageInspectionSummary{ID: "wpi_" + strings.ReplaceAll(uuid.NewString(), "-", ""), Status: "ready", ExpiresAt: time.Now().UTC().Add(30 * time.Minute), Package: workflowPackagePackageSummary{PackageFormat: inspected.Manifest.PackageFormat, FormatVersion: inspected.Manifest.FormatVersion, PackageID: inspected.Manifest.PackageID, PackageHash: inspected.PackageHash}, Workflow: workflowPackageWorkflowSummary{SourceID: inspected.Document.ID, Name: inspected.Document.Name, Description: inspected.Document.Description, SourceRevision: inspected.Document.Version, Enabled: inspected.Document.Enabled, ContentHash: inspected.ContentHash, GraphHash: inspected.GraphHash, NodeCount: inspected.NodeCount, EdgeCount: inspected.EdgeCount}, Conflict: workflowPackageConflictSummary{State: state}, Warnings: []string{}} + if local != nil { + content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local)) + summary.Conflict.LocalWorkflow = &workflowPackageLocalWorkflow{ID: local.ID, Version: local.Version, ContentHash: content, GraphHash: graph} + } + manifestJSON, _ := json.Marshal(inspected.Manifest) + payloadJSON, err := json.Marshal(inspected.Document) + if err != nil { + writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil) + return + } + inspectionJSON, _ := json.Marshal(summary) + record := &database.WorkflowPackageInspection{ID: summary.ID, PackageHash: inspected.PackageHash, ManifestJSON: string(manifestJSON), WorkflowPayloadJSON: string(payloadJSON), InspectionJSON: string(inspectionJSON), SourceWorkflowID: inspected.Document.ID, SourceRevision: inspected.Document.Version, SourceContentHash: inspected.ContentHash, SourceGraphHash: inspected.GraphHash, LocalConflictState: state, CreatedBy: session.UserID, CreatedAt: time.Now().UTC(), ExpiresAt: summary.ExpiresAt} + if local != nil { + content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(local)) + record.LocalWorkflowID = local.ID + record.LocalContentHash = content + record.LocalGraphHash = graph + } + if err := h.db.CreateWorkflowPackageInspection(record); err != nil { + writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "保存预检失败", nil) + return + } + if h.audit != nil { + h.audit.RecordOK(c, "workflow_package", "inspect", "工作流包预检成功", "inspection", record.ID, map[string]interface{}{"package_hash": record.PackageHash, "workflow_id": record.SourceWorkflowID}) + } + c.JSON(http.StatusCreated, gin.H{"inspection": summary}) +} + +func (h *WorkflowHandler) GetPackageInspection(c *gin.Context) { + session, ok := security.CurrentSession(c) + if !ok { + writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil) + return + } + v, err := h.db.GetWorkflowPackageInspection(c.Param("inspectionId"), session.UserID) + if err != nil { + writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取预检失败", nil) + return + } + if v == nil { + writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "预检不存在", nil) + return + } + if v.Status == "expired" { + writeWorkflowPackageError(c, http.StatusConflict, "WFPKG_INSPECTION_EXPIRED", "预检已过期", nil) + return + } + var summary any + _ = json.Unmarshal([]byte(v.InspectionJSON), &summary) + c.JSON(http.StatusOK, gin.H{"inspection": summary}) +} + +func (h *WorkflowHandler) ApplyPackageImport(c *gin.Context) { + session, ok := security.CurrentSession(c) + if !ok { + writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil) + return + } + key := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if _, err := uuid.Parse(key); err != nil { + writeWorkflowPackageError(c, http.StatusBadRequest, "WFPKG_IDEMPOTENCY_KEY_REQUIRED", "必须提供 UUID 幂等键", nil) + return + } + var req workflowPackageImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "导入请求无效", nil) + return + } + req.InspectionID = strings.TrimSpace(req.InspectionID) + req.Resolution.Action = strings.TrimSpace(req.Resolution.Action) + req.Resolution.NewWorkflowID = strings.TrimSpace(req.Resolution.NewWorkflowID) + if req.Resolution.Action != "rename" && req.Resolution.NewWorkflowID != "" { + writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_INVALID_ACTION", "当前导入动作不接受新工作流 ID", nil) + return + } + requestHash := workflowPackageRequestHash(req) + imp, replayed, err := h.db.ApplyWorkflowPackageImport(c.Request.Context(), database.WorkflowPackageApplyRequest{InspectionID: req.InspectionID, RequestHash: requestHash, IdempotencyKey: key, ActorUserID: session.UserID, Action: req.Resolution.Action, NewWorkflowID: req.Resolution.NewWorkflowID, ConfirmOverwrite: req.ConfirmOverwrite}) + if err != nil { + h.writeWorkflowPackageImportError(c, req.InspectionID, err) + return + } + wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID) + if !replayed && (imp.Result == "created" || imp.Result == "overwritten" || imp.Result == "renamed") { + workflowrunner.InvalidateCompiledCache(imp.ResultingWorkflowID) + } + response := h.workflowPackageImportResponse(imp, wf) + if !replayed && h.audit != nil { + h.audit.RecordOK(c, "workflow_package", "import", "工作流包导入成功", "workflow", imp.ResultingWorkflowID, map[string]interface{}{"inspection_id": imp.InspectionID, "action": imp.Action, "result": imp.Result}) + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + } + c.JSON(status, gin.H{"import": response}) +} + +func (h *WorkflowHandler) GetPackageImport(c *gin.Context) { + session, ok := security.CurrentSession(c) + if !ok { + writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil) + return + } + imp, err := h.db.GetWorkflowPackageImport(c.Param("importId"), session.UserID) + if err != nil { + writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "读取导入结果失败", nil) + return + } + if imp == nil { + writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_INSPECTION_NOT_FOUND", "导入结果不存在", nil) + return + } + wf, _ := h.db.GetWorkflowDefinition(imp.ResultingWorkflowID) + c.JSON(http.StatusOK, gin.H{"import": h.workflowPackageImportResponse(imp, wf)}) +} + +func (h *WorkflowHandler) workflowPackageConflict(id, sourceHash string) (string, *database.WorkflowDefinition, error) { + local, err := h.db.GetWorkflowDefinition(id) + if err != nil { + return "", nil, err + } + if local == nil { + return "none", nil, nil + } + localHash, _, _, err := workflowpkg.DocumentHashes(workflowPackageDocument(local)) + if err != nil { + return "", nil, err + } + if localHash == sourceHash { + return "identical", local, nil + } + return "id_conflict", local, nil +} +func workflowPackageDocument(w *database.WorkflowDefinition) workflowpkg.Document { + return workflowpkg.Document{ID: w.ID, Name: w.Name, Description: w.Description, Version: w.Version, GraphJSON: w.GraphJSON, Enabled: w.Enabled, UpdatedAt: w.UpdatedAt} +} +func workflowPackageRequestHash(req workflowPackageImportRequest) string { + value := struct { + ConfirmOverwrite bool `json:"confirm_overwrite"` + InspectionID string `json:"inspection_id"` + Resolution workflowPackageResolution `json:"resolution"` + }{req.ConfirmOverwrite, req.InspectionID, req.Resolution} + b, _ := json.Marshal(value) + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} +func workflowPackageHash(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func (h *WorkflowHandler) writeWorkflowPackageImportError(c *gin.Context, inspectionID string, err error) { + var e *database.WorkflowPackageStoreError + if errors.As(err, &e) { + status := http.StatusConflict + if e.Code == "WFPKG_INVALID_ACTION" || e.Code == "WFPKG_INVALID_RENAME_ID" { + status = http.StatusUnprocessableEntity + } + if h.audit != nil { + h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": e.Code, "inspection_id": inspectionID}) + } + writeWorkflowPackageError(c, status, e.Code, e.Message, nil) + return + } + if h.audit != nil { + h.audit.RecordFail(c, "workflow_package", "import", "工作流包导入失败", map[string]interface{}{"code": "WFPKG_IMPORT_FAILED", "inspection_id": inspectionID}) + } + writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_IMPORT_FAILED", "导入工作流包失败", nil) +} +func writeWorkflowPackageError(c *gin.Context, status int, code, message string, details map[string]any) { + body := gin.H{"code": code, "message": message} + if len(details) > 0 { + body["details"] = details + } + c.JSON(status, gin.H{"error": body}) +} + +type workflowPackageInspectionSummary struct { + ID string `json:"id"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + Package workflowPackagePackageSummary `json:"package"` + Workflow workflowPackageWorkflowSummary `json:"workflow"` + Conflict workflowPackageConflictSummary `json:"conflict"` + Warnings []string `json:"warnings"` +} +type workflowPackagePackageSummary struct { + PackageFormat string `json:"package_format"` + FormatVersion string `json:"format_version"` + PackageID string `json:"package_id"` + PackageHash string `json:"package_hash"` +} +type workflowPackageWorkflowSummary struct { + SourceID string `json:"source_id"` + Name string `json:"name"` + Description string `json:"description"` + SourceRevision int `json:"source_revision"` + Enabled bool `json:"enabled"` + ContentHash string `json:"content_hash"` + GraphHash string `json:"graph_hash"` + NodeCount int `json:"node_count"` + EdgeCount int `json:"edge_count"` +} +type workflowPackageConflictSummary struct { + State string `json:"state"` + LocalWorkflow *workflowPackageLocalWorkflow `json:"local_workflow,omitempty"` +} +type workflowPackageLocalWorkflow struct { + ID string `json:"id"` + Version int `json:"version"` + ContentHash string `json:"content_hash"` + GraphHash string `json:"graph_hash"` +} + +func (h *WorkflowHandler) workflowPackageImportResponse(imp *database.WorkflowPackageImport, wf *database.WorkflowDefinition) gin.H { + out := gin.H{"id": imp.ID, "inspection_id": imp.InspectionID, "status": "succeeded", "result": imp.Result, "action": imp.Action, "source_workflow_id": imp.SourceWorkflowID, "target_workflow_id": imp.TargetWorkflowID, "applied_at": imp.AppliedAt} + if wf != nil { + content, graph, _, _ := workflowpkg.DocumentHashes(workflowPackageDocument(wf)) + out["workflow"] = gin.H{"id": wf.ID, "version": wf.Version, "content_hash": content, "graph_hash": graph} + } + return out +} diff --git a/internal/handler/workflow_package_test.go b/internal/handler/workflow_package_test.go new file mode 100644 index 00000000..f8181260 --- /dev/null +++ b/internal/handler/workflow_package_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + workflowpkg "cyberstrike-ai/internal/workflow/package" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + h := NewWorkflowHandler(db, zap.NewNop()) + pkg, _, err := workflowpkg.Export(workflowpkg.Document{ID: "wf-api", Name: "API workflow", Version: 4, Enabled: true, UpdatedAt: time.Now().UTC(), GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`}) + if err != nil { + t.Fatal(err) + } + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "wf-api.csapkg.zip") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(pkg); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/workflow-package-inspections", &body) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + c.Set(security.ContextSessionKey, security.Session{UserID: "user-1"}) + h.CreatePackageInspection(c) + if w.Code != http.StatusCreated { + t.Fatalf("inspection status=%d body=%s", w.Code, w.Body.String()) + } + var inspected struct { + Inspection struct { + ID string `json:"id"` + } `json:"inspection"` + } + if err := json.Unmarshal(w.Body.Bytes(), &inspected); err != nil { + t.Fatal(err) + } + applyBody := bytes.NewBufferString(`{"inspection_id":"` + inspected.Inspection.ID + `","resolution":{"action":"create","new_workflow_id":""},"confirm_overwrite":false}`) + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/workflow-package-imports", applyBody) + c.Request.Header.Set("Content-Type", "application/json") + c.Request.Header.Set("Idempotency-Key", uuid.NewString()) + c.Set(security.ContextSessionKey, security.Session{UserID: "user-1"}) + h.ApplyPackageImport(c) + if w.Code != http.StatusCreated { + t.Fatalf("import status=%d body=%s", w.Code, w.Body.String()) + } + saved, _ := db.GetWorkflowDefinition("wf-api") + if saved == nil || saved.Version != 1 { + t.Fatalf("saved=%#v", saved) + } +} diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index 362ea64b..6486476a 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -164,6 +164,10 @@ func permissionForRequest(method, fullPath string) string { return crudPermission(method, "files") case strings.HasPrefix(path, "/roles"): return crudPermission(method, "roles") + case path == "/workflows/:id/package": + return "workflow:read" + case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"): + return "workflow:write" case strings.HasPrefix(path, "/workflows"): if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") { return "workflow:execute" @@ -257,6 +261,9 @@ func isProcessGlobalMutationPath(path string) bool { // Workflow runs inherit conversation access; definitions are global. return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" } + if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") { + return true + } if strings.HasPrefix(path, "/knowledge") { return path != "/knowledge/search" } diff --git a/internal/security/workflow_package_rbac_test.go b/internal/security/workflow_package_rbac_test.go new file mode 100644 index 00000000..2f1fb1bc --- /dev/null +++ b/internal/security/workflow_package_rbac_test.go @@ -0,0 +1,20 @@ +package security + +import ( + "net/http" + "testing" +) + +func TestWorkflowPackageRoutesHaveExplicitWorkflowPermissions(t *testing.T) { + if got := permissionForRequest(http.MethodGet, "/api/workflows/:id/package"); got != "workflow:read" { + t.Fatalf("export permission=%q", got) + } + for _, path := range []string{"/api/workflow-package-inspections", "/api/workflow-package-inspections/:inspectionId", "/api/workflow-package-imports", "/api/workflow-package-imports/:importId"} { + if got := permissionForRequest(http.MethodGet, path); got != "workflow:write" { + t.Fatalf("%s permission=%q", path, got) + } + } + if !isProcessGlobalMutationPath("/workflow-package-imports") || !isProcessGlobalMutationPath("/workflow-package-inspections") { + t.Fatal("package mutations must require all-resource scope") + } +} diff --git a/internal/workflow/package/exporter.go b/internal/workflow/package/exporter.go new file mode 100644 index 00000000..46494354 --- /dev/null +++ b/internal/workflow/package/exporter.go @@ -0,0 +1,98 @@ +package workflowpackage + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "path" + "strings" + "time" +) + +// Export builds a deterministic, human-readable single-workflow package. +func Export(source Document) ([]byte, ExportMetadata, error) { + source.ID = strings.TrimSpace(source.ID) + source.Name = strings.TrimSpace(source.Name) + if source.ID == "" || source.Name == "" || source.Version <= 0 || !safePackageWorkflowID(source.ID) { + return nil, ExportMetadata{}, fmt.Errorf("workflow id, name and version are required") + } + if strings.TrimSpace(source.GraphJSON) == "" { + return nil, ExportMetadata{}, fmt.Errorf("workflow graph_json is required") + } + contentHash, graphHash, payload, err := DocumentHashes(source) + if err != nil { + return nil, ExportMetadata{}, err + } + workflowPath := path.Join("workflows", source.ID+".json") + createdAt := source.UpdatedAt.UTC() + if createdAt.IsZero() { + createdAt = time.Unix(0, 0).UTC() + } + manifest := Manifest{ + PackageFormat: PackageFormat, + FormatVersion: FormatVersion, + PackageID: "pkg_" + strings.TrimPrefix(contentHash, "sha256:")[:16], + CreatedAt: createdAt.Format(time.RFC3339), + Items: []ManifestItem{{ + Type: "workflow", + Path: workflowPath, + SourceID: source.ID, + SourceRevision: source.Version, + ContentHash: contentHash, + GraphHash: graphHash, + }}, + } + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return nil, ExportMetadata{}, fmt.Errorf("marshal manifest: %w", err) + } + checksums := fmt.Sprintf("%s manifest.json\n%s %s\n", strings.TrimPrefix(sha256Prefixed(manifestBytes), "sha256:"), strings.TrimPrefix(contentHash, "sha256:"), workflowPath) + + var out bytes.Buffer + zw := zip.NewWriter(&out) + for _, entry := range []struct { + name string + data []byte + }{ + {name: "checksums.sha256", data: []byte(checksums)}, + {name: "manifest.json", data: manifestBytes}, + {name: workflowPath, data: payload}, + } { + header := &zip.FileHeader{Name: entry.name, Method: zip.Store} + header.SetModTime(time.Unix(0, 0).UTC()) + writer, err := zw.CreateHeader(header) + if err != nil { + return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err) + } + if _, err := writer.Write(entry.data); err != nil { + return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err) + } + } + if err := zw.Close(); err != nil { + return nil, ExportMetadata{}, fmt.Errorf("close package: %w", err) + } + pkg := out.Bytes() + return pkg, ExportMetadata{ + PackageHash: sha256Prefixed(pkg), + ContentHash: contentHash, + GraphHash: graphHash, + SourceRevision: source.Version, + FileName: source.ID + ".csapkg.zip", + }, nil +} + +// DocumentHashes returns the canonical package item and graph hashes together +// with the canonical item bytes used by export and inspection persistence. +func DocumentHashes(source Document) (string, string, []byte, error) { + graph, err := canonicalJSON([]byte(source.GraphJSON)) + if err != nil { + return "", "", nil, fmt.Errorf("canonicalize graph_json: %w", err) + } + source.GraphJSON = string(graph) + payload, err := json.Marshal(source) + if err != nil { + return "", "", nil, fmt.Errorf("marshal workflow payload: %w", err) + } + return sha256Prefixed(payload), sha256Prefixed(graph), payload, nil +} diff --git a/internal/workflow/package/exporter_test.go b/internal/workflow/package/exporter_test.go new file mode 100644 index 00000000..4b13fa48 --- /dev/null +++ b/internal/workflow/package/exporter_test.go @@ -0,0 +1,58 @@ +package workflowpackage + +import ( + "archive/zip" + "bytes" + "strings" + "testing" + "time" +) + +func testDocument() Document { + return Document{ + ID: "web-src-hunting", + Name: "Web SRC 猎洞", + Description: "面向 SRC Web 资产的侦察与漏洞候选流程", + Version: 18, + Enabled: true, + GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`, + UpdatedAt: time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC), + } +} + +func TestExportIsDeterministicAndSelfDescribing(t *testing.T) { + first, firstMeta, err := Export(testDocument()) + if err != nil { + t.Fatalf("first export: %v", err) + } + second, secondMeta, err := Export(testDocument()) + if err != nil { + t.Fatalf("second export: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatal("identical document must produce byte-identical package") + } + if firstMeta.PackageHash != secondMeta.PackageHash || !strings.HasPrefix(firstMeta.PackageHash, "sha256:") { + t.Fatalf("unexpected deterministic package hash: %#v / %#v", firstMeta, secondMeta) + } + + zr, err := zip.NewReader(bytes.NewReader(first), int64(len(first))) + if err != nil { + t.Fatalf("open package: %v", err) + } + if len(zr.File) != 3 { + t.Fatalf("zip entry count = %d, want 3", len(zr.File)) + } + wantNames := []string{"checksums.sha256", "manifest.json", "workflows/web-src-hunting.json"} + for i, f := range zr.File { + if f.Name != wantNames[i] { + t.Fatalf("entry %d = %q, want %q", i, f.Name, wantNames[i]) + } + } + if firstMeta.SourceRevision != 18 { + t.Fatalf("source revision = %d, want 18", firstMeta.SourceRevision) + } + if !strings.HasPrefix(firstMeta.ContentHash, "sha256:") || !strings.HasPrefix(firstMeta.GraphHash, "sha256:") { + t.Fatalf("content/graph hashes must be sha256: %#v", firstMeta) + } +} diff --git a/internal/workflow/package/inspector.go b/internal/workflow/package/inspector.go new file mode 100644 index 00000000..7d533563 --- /dev/null +++ b/internal/workflow/package/inspector.go @@ -0,0 +1,225 @@ +package workflowpackage + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "strings" + "unicode" +) + +const ( + MaxArchiveBytes = 10 << 20 + MaxExtractedBytes = 20 << 20 +) + +// PackageError contains only a contract error code and safe, client-facing fields. +type PackageError struct { + Code string + Message string + Details map[string]any +} + +func (e *PackageError) Error() string { return e.Code + ": " + e.Message } + +func packageError(code, message string) error { + return &PackageError{Code: code, Message: message} +} + +// ErrorCode returns a package contract code without exposing internal errors. +func ErrorCode(err error) string { + var target *PackageError + if errors.As(err, &target) { + return target.Code + } + return "" +} + +type InspectionResult struct { + PackageHash string + Manifest Manifest + Document Document + ContentHash string + GraphHash string + NodeCount int + EdgeCount int +} + +// InspectArchive verifies an archive without executing any package content. +// validateGraph is injected by the application so this format package has no +// dependency on the workflow runtime or database driver. +func InspectArchive(ctx context.Context, archive []byte, validateGraph func(context.Context, string) error) (*InspectionResult, error) { + if len(archive) == 0 { + return nil, packageError("WFPKG_FILE_REQUIRED", "必须上传工作流包文件") + } + if len(archive) > MaxArchiveBytes { + return nil, packageError("WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制") + } + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包不是有效 ZIP 文件") + } + entries := make(map[string][]byte, len(zr.File)) + var extracted int64 + for _, file := range zr.File { + if !safeArchivePath(file.Name) || file.FileInfo().IsDir() || file.FileInfo().Mode()&os.ModeSymlink != 0 { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含不安全文件路径") + } + if _, exists := entries[file.Name]; exists { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含重复文件") + } + if file.UncompressedSize64 > MaxExtractedBytes || extracted+int64(file.UncompressedSize64) > MaxExtractedBytes { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制") + } + reader, err := file.Open() + if err != nil { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "无法读取工作流包文件") + } + data, readErr := io.ReadAll(io.LimitReader(reader, int64(MaxExtractedBytes)-extracted+1)) + closeErr := reader.Close() + if readErr != nil || closeErr != nil || len(data) > MaxExtractedBytes-int(extracted) { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制") + } + extracted += int64(len(data)) + entries[file.Name] = data + } + + manifestRaw, hasManifest := entries["manifest.json"] + checksumsRaw, hasChecksums := entries["checksums.sha256"] + if !hasManifest || !hasChecksums { + return nil, packageError("WFPKG_UNSUPPORTED_FORMAT", "工作流包缺少必需文件") + } + manifest, err := parseManifest(manifestRaw) + if err != nil { + return nil, err + } + if len(manifest.Items) != 1 || manifest.Items[0].Type != "workflow" { + return nil, packageError("WFPKG_MULTIPLE_WORKFLOWS", "工作流包必须且只能包含一个工作流") + } + item := manifest.Items[0] + workflowRaw, exists := entries[item.Path] + if !exists || !safeWorkflowPath(item.Path) || len(entries) != 3 { + return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含未声明文件") + } + checksums, err := parseChecksums(checksumsRaw) + if err != nil { + return nil, err + } + if len(checksums) != 2 || checksums["manifest.json"] != sha256Prefixed(manifestRaw) || checksums[item.Path] != sha256Prefixed(workflowRaw) { + return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和不匹配") + } + if item.ContentHash != sha256Prefixed(workflowRaw) || !validHash(item.ContentHash) || !validHash(item.GraphHash) { + return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包内容校验和不匹配") + } + doc, err := parseDocument(workflowRaw) + if err != nil { + return nil, err + } + if !safePackageWorkflowID(doc.ID) || doc.ID != item.SourceID || doc.Version != item.SourceRevision { + return nil, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单与工作流内容不一致") + } + graph, err := canonicalJSON([]byte(doc.GraphJSON)) + if err != nil || item.GraphHash != sha256Prefixed(graph) { + return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流图校验和不匹配") + } + if validateGraph == nil || validateGraph(ctx, string(graph)) != nil { + return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图校验失败") + } + var graphShape struct { + Nodes []json.RawMessage `json:"nodes"` + Edges []json.RawMessage `json:"edges"` + } + if err := json.Unmarshal(graph, &graphShape); err != nil { + return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图不是有效 JSON") + } + return &InspectionResult{ + PackageHash: sha256Prefixed(archive), + Manifest: manifest, + Document: doc, + ContentHash: item.ContentHash, + GraphHash: item.GraphHash, + NodeCount: len(graphShape.Nodes), + EdgeCount: len(graphShape.Edges), + }, nil +} + +func safeArchivePath(name string) bool { + return name != "" && !strings.Contains(name, `\`) && !strings.HasPrefix(name, "/") && path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".." +} + +func safeWorkflowPath(name string) bool { + rest := strings.TrimPrefix(name, "workflows/") + return safeArchivePath(name) && strings.HasPrefix(name, "workflows/") && rest != "" && !strings.Contains(rest, "/") && strings.HasSuffix(rest, ".json") +} + +func safePackageWorkflowID(id string) bool { + if id == "" || strings.ContainsAny(id, `/\`) { + return false + } + for _, r := range id { + if unicode.IsControl(r) { + return false + } + } + return true +} + +func parseManifest(raw []byte) (Manifest, error) { + var manifest Manifest + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&manifest); err != nil { + return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式无效") + } + if err := consumeJSONEnd(dec); err != nil || manifest.PackageFormat != PackageFormat || manifest.FormatVersion != FormatVersion || strings.TrimSpace(manifest.PackageID) == "" || len(manifest.Items) == 0 { + return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式不受支持") + } + return manifest, nil +} + +func parseDocument(raw []byte) (Document, error) { + var doc Document + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&doc); err != nil || consumeJSONEnd(dec) != nil { + return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义格式无效") + } + doc.ID = strings.TrimSpace(doc.ID) + doc.Name = strings.TrimSpace(doc.Name) + if doc.ID == "" || doc.Name == "" || doc.Version <= 0 || strings.TrimSpace(doc.GraphJSON) == "" { + return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义缺少必需字段") + } + return doc, nil +} + +func consumeJSONEnd(dec *json.Decoder) error { + var extra any + if err := dec.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values") + } + return err + } + return nil +} + +func parseChecksums(raw []byte) (map[string]string, error) { + entries := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), " ", 2) + if len(parts) != 2 || !validHash("sha256:"+parts[0]) || !safeArchivePath(parts[1]) { + return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和格式无效") + } + if _, exists := entries[parts[1]]; exists { + return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和重复") + } + entries[parts[1]] = "sha256:" + parts[0] + } + return entries, nil +} diff --git a/internal/workflow/package/inspector_test.go b/internal/workflow/package/inspector_test.go new file mode 100644 index 00000000..4847eabd --- /dev/null +++ b/internal/workflow/package/inspector_test.go @@ -0,0 +1,154 @@ +package workflowpackage + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "testing" +) + +func TestInspectArchiveAcceptsSingleVerifiedWorkflow(t *testing.T) { + pkg, meta, err := Export(testDocument()) + if err != nil { + t.Fatal(err) + } + result, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return nil }) + if err != nil { + t.Fatalf("InspectArchive: %v", err) + } + if result.PackageHash != meta.PackageHash || result.Document.ID != "web-src-hunting" { + t.Fatalf("unexpected inspection: %#v", result) + } + if result.NodeCount != 2 || result.EdgeCount != 1 { + t.Fatalf("counts = %d/%d, want 2/1", result.NodeCount, result.EdgeCount) + } +} + +func TestInspectArchiveRejectsUnsafeArchiveShapes(t *testing.T) { + valid, _, err := Export(testDocument()) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + archive []byte + }{ + {name: "duplicate entry", archive: appendZipEntry(t, valid, "manifest.json", []byte(`{}`), 0)}, + {name: "path traversal", archive: appendZipEntry(t, valid, "../payload.json", []byte(`{}`), 0)}, + {name: "symlink", archive: appendZipEntry(t, valid, "workflows/link.json", []byte("target"), 0o120777)}, + {name: "undeclared file", archive: appendZipEntry(t, valid, "notes.txt", []byte("not allowed"), 0)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := InspectArchive(context.Background(), tc.archive, func(context.Context, string) error { return nil }) + if ErrorCode(err) != "WFPKG_INVALID_ARCHIVE" { + t.Fatalf("code = %q, err = %v", ErrorCode(err), err) + } + }) + } +} + +func TestInspectArchiveRejectsChecksumMismatchAndInvalidWorkflow(t *testing.T) { + pkg, _, err := Export(testDocument()) + if err != nil { + t.Fatal(err) + } + badChecksum := replaceZipEntry(t, pkg, "checksums.sha256", []byte("00 manifest.json\n"), 0) + if _, err := InspectArchive(context.Background(), badChecksum, func(context.Context, string) error { return nil }); ErrorCode(err) != "WFPKG_CHECKSUM_MISMATCH" { + t.Fatalf("checksum code = %q, err = %v", ErrorCode(err), err) + } + if _, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return errors.New("invalid graph") }); ErrorCode(err) != "WFPKG_WORKFLOW_INVALID" { + t.Fatalf("graph code = %q, err = %v", ErrorCode(err), err) + } +} + +func appendZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte { + t.Helper() + return rewriteZip(t, archive, func(zw *zip.Writer) error { + h := &zip.FileHeader{Name: name, Method: zip.Store} + h.SetMode(mode) + w, err := zw.CreateHeader(h) + if err != nil { + return err + } + _, err = w.Write(data) + return err + }) +} + +func replaceZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + zw := zip.NewWriter(&out) + for _, f := range zr.File { + if f.Name == name { + h := &zip.FileHeader{Name: name, Method: zip.Store} + h.SetMode(mode) + w, err := zw.CreateHeader(h) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + continue + } + r, err := f.Open() + if err != nil { + t.Fatal(err) + } + h := &zip.FileHeader{Name: f.Name, Method: zip.Store} + w, err := zw.CreateHeader(h) + if err == nil { + _, err = io.Copy(w, r) + } + _ = r.Close() + if err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return out.Bytes() +} + +func rewriteZip(t *testing.T, archive []byte, appendEntry func(*zip.Writer) error) []byte { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + zw := zip.NewWriter(&out) + for _, f := range zr.File { + r, err := f.Open() + if err != nil { + t.Fatal(err) + } + h := &zip.FileHeader{Name: f.Name, Method: zip.Store} + w, err := zw.CreateHeader(h) + if err == nil { + _, err = io.Copy(w, r) + } + _ = r.Close() + if err != nil { + t.Fatal(err) + } + } + if err := appendEntry(zw); err != nil { + t.Fatal(fmt.Errorf("append entry: %w", err)) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return out.Bytes() +} diff --git a/internal/workflow/package/manifest.go b/internal/workflow/package/manifest.go new file mode 100644 index 00000000..a84e263b --- /dev/null +++ b/internal/workflow/package/manifest.go @@ -0,0 +1,78 @@ +package workflowpackage + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" +) + +const ( + PackageFormat = "cyberstrikeai.workflow-package" + FormatVersion = "1.0" +) + +// Document is the single non-executable workflow definition carried by a package. +// Version is the source instance revision and is never applied as a target version. +type Document struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version int `json:"version"` + GraphJSON string `json:"graph_json"` + Enabled bool `json:"enabled"` + UpdatedAt time.Time `json:"-"` +} + +type Manifest struct { + PackageFormat string `json:"package_format"` + FormatVersion string `json:"format_version"` + PackageID string `json:"package_id"` + CreatedAt string `json:"created_at"` + Items []ManifestItem `json:"items"` +} + +type ManifestItem struct { + Type string `json:"type"` + Path string `json:"path"` + SourceID string `json:"source_id"` + SourceRevision int `json:"source_revision"` + ContentHash string `json:"content_hash"` + GraphHash string `json:"graph_hash"` +} + +type ExportMetadata struct { + PackageHash string + ContentHash string + GraphHash string + SourceRevision int + FileName string +} + +func canonicalJSON(raw []byte) ([]byte, error) { + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.UseNumber() + var value any + if err := dec.Decode(&value); err != nil { + return nil, err + } + if dec.More() { + return nil, fmt.Errorf("extra JSON values") + } + return json.Marshal(value) +} + +func sha256Prefixed(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func validHash(value string) bool { + if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 { + return false + } + _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil && value == strings.ToLower(value) +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 0a66eab3..59da76f2 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -10544,6 +10544,240 @@ html[data-theme="dark"] .robot-binding-one-time-badge { } } +/* Workflow package import */ +.workflow-package-modal-content { + width: min(1040px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + margin: 24px auto; +} + +.workflow-package-modal-header { + align-items: flex-start; +} + +.workflow-package-modal-header h2 { + margin: 0; +} + +.workflow-package-subtitle, +.workflow-package-file-hint { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + +.workflow-package-steps { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + padding: 12px 22px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.workflow-package-steps > span { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; +} + +.workflow-package-steps b { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 1px solid var(--border-color); + border-radius: 999px; + font-size: 12px; +} + +.workflow-package-steps .is-active { + color: var(--text-primary); +} + +.workflow-package-steps .is-active b { + border-color: var(--accent-color); + background: var(--accent-color); + color: #fff; +} + +.workflow-package-modal-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + padding: 18px 22px; +} + +.workflow-package-pane { + min-width: 0; + padding: 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.workflow-package-pane h3 { + margin: 0 0 12px; + font-size: 14px; +} + +.workflow-package-file-row { + display: flex; + align-items: center; + gap: 10px; +} + +.workflow-package-file-name { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-package-pane--inspection > .btn-primary { + margin-top: 12px; +} + +.workflow-package-summary, +.workflow-package-resolution { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.workflow-package-summary:empty, +.workflow-package-resolution:empty { + display: none; +} + +.workflow-package-summary-row { + display: grid; + grid-template-columns: minmax(88px, 0.65fr) minmax(0, 1fr); + gap: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); + font-size: 12px; +} + +.workflow-package-summary-row span { + color: var(--text-secondary); +} + +.workflow-package-summary-row code { + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-package-status { + padding: 10px 12px; + border: 1px solid rgba(59, 130, 246, 0.35); + border-radius: 7px; + background: rgba(59, 130, 246, 0.08); + color: var(--text-primary); + font-size: 13px; + line-height: 1.55; +} + +.workflow-package-status.is-warning { + border-color: rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.1); +} + +.workflow-package-status.is-success { + border-color: rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.1); +} + +.workflow-package-choice, +.workflow-package-reveal { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 7px; + background: var(--bg-secondary); + cursor: pointer; +} + +.workflow-package-choice.is-selected { + border-color: var(--accent-color); + background: rgba(59, 130, 246, 0.08); +} + +.workflow-package-choice input, +.workflow-package-confirm-check input { + margin-top: 3px; +} + +.workflow-package-choice strong, +.workflow-package-choice small { + display: block; +} + +.workflow-package-choice small { + margin-top: 4px; + color: var(--text-secondary); + line-height: 1.45; +} + +.workflow-package-reveal { + width: 100%; + color: var(--text-secondary); + font: inherit; + text-align: left; +} + +.workflow-package-rename-field { + display: grid; + gap: 6px; + font-size: 12px; + font-weight: 600; +} + +.workflow-package-rename-field input { + width: 100%; + box-sizing: border-box; +} + +.workflow-package-confirm-check { + display: flex; + align-items: flex-start; + gap: 8px; + line-height: 1.5; +} + +.workflow-package-overwrite-content { + width: min(480px, calc(100vw - 32px)); + margin: 18vh auto auto; +} + +@media (max-width: 760px) { + .workflow-package-modal-content { + width: calc(100vw - 20px); + margin: 10px auto; + } + + .workflow-package-modal-body { + grid-template-columns: minmax(0, 1fr); + padding: 14px; + } + + .workflow-package-steps { + gap: 8px; + padding: 10px 14px; + } +} + /* RBAC workspace: keep navigation stable and long resource collections bounded. */ .platform-rbac-page > .page-content { display: flex; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index f154d39d..2d3d34ac 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3131,6 +3131,93 @@ "enabledUpdateFailed": "Failed to update enabled status", "namePlaceholder": "Basic Web scan", "descriptionPlaceholder": "Optional", + "package": { + "importLocal": "Import local package", + "export": "Export", + "title": "Import local package", + "preflightHint": "Preflight does not modify local workflows", + "stepInspection": "Upload and preflight", + "stepImport": "Resolve conflict and import", + "stepsLabel": "Import steps", + "selectFile": "Choose local package", + "noFile": "No file selected", + "fileHint": "One .csapkg.zip package only, up to 10 MiB.", + "inspect": "Upload and preflight", + "resolutionTitle": "Resolution", + "confirmImport": "Confirm import", + "overwriteTitle": "Overwrite local workflow?", + "overwriteWarning": "This replaces the name, description, graph, and enabled state. Rollback is not available in this release.", + "overwriteCheck": "I confirm overwriting the current local workflow", + "overwriteConfirm": "Overwrite and import", + "errors": { + "fileRequired": "Choose a local workflow package before preflight.", + "fileTooLarge": "The file exceeds the 10 MiB limit. Choose a smaller local package.", + "invalidArchive": "The local package is not a valid Zip archive and cannot be preflighted.", + "unsupportedFormat": "This local package format or version is not supported.", + "invalidManifest": "The local package manifest is invalid and cannot be preflighted.", + "checksumMismatch": "Local package verification failed; the file may be damaged or modified.", + "multipleWorkflows": "This release supports local packages containing one workflow only.", + "workflowInvalid": "The workflow definition in the local package is invalid and cannot be preflighted.", + "inspectionNotFound": "The preflight record no longer exists. Upload and preflight again.", + "inspectionExpired": "The preflight has expired. Upload and preflight again.", + "inspectionConsumed": "This preflight has already been imported and cannot be reused.", + "conflictChanged": "The local workflow changed after preflight. Run preflight again before importing.", + "idConflict": "The selected resolution no longer matches the current conflict state. Run preflight again.", + "invalidAction": "The import resolution is invalid. Choose it again.", + "invalidRenameId": "Enter an available new workflow ID.", + "overwriteConfirmationRequired": "Confirm the overwrite before replacing the local workflow.", + "idempotencyKeyRequired": "The browser cannot create an import request identifier. Refresh and try again.", + "idempotencyKeyReused": "The import request state is invalid. Run preflight again and retry.", + "importFailed": "Import failed; the local workflow was not changed.", + "exportFailed": "Export failed. Try again later.", + "workflowNotFound": "The workflow was not found and cannot be exported.", + "generic": "The operation was not completed. Try again later.", + "serviceUnavailable": "The import service is not ready. Refresh the page and try again." + }, + "conflict": { + "identical": "A local workflow with the same ID and content was found; import will be skipped.", + "idConflict": "A local workflow with the same ID but different content was found. The local version will be kept by default.", + "none": "No local workflow with the same ID was found. A new workflow can be created." + }, + "summary": { + "workflowName": "Workflow name", + "unnamedWorkflow": "Untitled workflow", + "sourceId": "Source workflow ID", + "sourceRevision": "Source revision", + "graphSize": "Graph size", + "graphSizeValue": "{{nodes}} nodes · {{edges}} edges", + "contentHash": "Content hash", + "expiresAt": "Preflight expires" + }, + "resolution": { + "needInspection": "Choose a local package and complete preflight first.", + "createHint": "Import will create a new local workflow.", + "identicalHint": "The same content already exists. No duplicate import is needed.", + "keepExisting": "Keep local version", + "keepExistingHint": "Do not change the current local workflow; the import record is retained.", + "revealRiskChoices": "Change resolution", + "overwrite": "Overwrite local version", + "overwriteHint": "Replace the name, description, graph, and enabled state; a second confirmation is required.", + "rename": "Save under a new ID", + "renameHint": "Keep the current local workflow and create a copy under a new ID.", + "newWorkflowId": "New workflow ID", + "newWorkflowIdPlaceholder": "For example: web-scan-basic-copy", + "continueOverwrite": "Continue to overwrite confirmation", + "confirmRename": "Confirm save as new ID", + "confirmCreate": "Confirm create", + "confirmComplete": "Confirm completion" + }, + "result": { + "created": "Import completed: a new local workflow was created.", + "overwritten": "Import completed: the local workflow was overwritten.", + "renamed": "Import completed: a local workflow was created under the new ID.", + "keptExisting": "The local workflow was kept; no changes were made.", + "skippedIdentical": "The same local content already exists; import was skipped.", + "complete": "Import completed.", + "completedAction": "Import completed" + }, + "exportSelectSaved": "Choose a saved workflow before exporting." + }, "connect": "Connect", "connecting": "Connecting", "deleteSelected": "Delete selected", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 5de2dcb2..7cfbaef2 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3119,6 +3119,93 @@ "enabledUpdateFailed": "更新启用状态失败", "namePlaceholder": "基础 Web 扫描", "descriptionPlaceholder": "可选", + "package": { + "importLocal": "导入本地包", + "export": "导出", + "title": "导入本地包", + "preflightHint": "预检不会修改本地工作流", + "stepInspection": "上传与预检", + "stepImport": "处理冲突与导入", + "stepsLabel": "导入步骤", + "selectFile": "选择本地包", + "noFile": "未选择文件", + "fileHint": "仅支持单个 .csapkg.zip,文件不得超过 10 MiB。", + "inspect": "上传并预检", + "resolutionTitle": "处理方式", + "confirmImport": "确认导入", + "overwriteTitle": "确认覆盖本地工作流?", + "overwriteWarning": "覆盖将替换名称、描述、图定义和启用状态。本期不提供回滚。", + "overwriteCheck": "我已确认覆盖当前本地工作流", + "overwriteConfirm": "确认覆盖并导入", + "errors": { + "fileRequired": "请选择本地图编排包后再预检。", + "fileTooLarge": "文件超过 10 MiB 限制,请选择更小的本地包。", + "invalidArchive": "本地包不是有效的 Zip 文件,无法完成预检。", + "unsupportedFormat": "本地包格式或版本不受支持。", + "invalidManifest": "本地包清单无效,无法完成预检。", + "checksumMismatch": "本地包校验失败,文件可能已损坏或被修改。", + "multipleWorkflows": "本期仅支持导入包含一个工作流的本地包。", + "workflowInvalid": "本地包中的工作流定义无效,无法完成预检。", + "inspectionNotFound": "预检记录不存在,请重新上传并预检。", + "inspectionExpired": "预检已过期,请重新上传并预检。", + "inspectionConsumed": "该预检已完成导入,不能重复使用。", + "conflictChanged": "本地工作流在预检后发生变化,请重新预检后再导入。", + "idConflict": "当前处理方式与最新冲突状态不匹配,请重新预检。", + "invalidAction": "导入处理方式无效,请重新选择。", + "invalidRenameId": "请输入可用的新工作流 ID。", + "overwriteConfirmationRequired": "请勾选确认后再覆盖本地工作流。", + "idempotencyKeyRequired": "浏览器无法生成导入请求标识,请刷新页面后重试。", + "idempotencyKeyReused": "导入请求状态异常,请重新预检后再试。", + "importFailed": "导入失败,本地工作流未被修改。", + "exportFailed": "导出失败,请稍后重试。", + "workflowNotFound": "未找到该工作流,无法导出。", + "generic": "操作未完成,请稍后重试。", + "serviceUnavailable": "导入服务尚未准备好,请刷新页面后重试。" + }, + "conflict": { + "identical": "检测到同 ID 且内容相同的本地工作流;本次将跳过导入。", + "idConflict": "检测到同 ID 但内容不同的本地工作流。默认保留本地版本。", + "none": "未发现同 ID 的本地工作流,可创建导入。" + }, + "summary": { + "workflowName": "工作流名称", + "unnamedWorkflow": "未命名工作流", + "sourceId": "源工作流 ID", + "sourceRevision": "源版本", + "graphSize": "图规模", + "graphSizeValue": "{{nodes}} 个节点 · {{edges}} 条连线", + "contentHash": "内容摘要", + "expiresAt": "预检有效期" + }, + "resolution": { + "needInspection": "请先选择本地包并完成预检。", + "createHint": "导入后会创建一个新的本地工作流。", + "identicalHint": "内容已经存在,无需重复导入。", + "keepExisting": "保留本地版本", + "keepExistingHint": "不修改当前本地工作流;导入记录会保留。", + "revealRiskChoices": "更改处理方式", + "overwrite": "覆盖本地版本", + "overwriteHint": "替换名称、描述、图定义和启用状态;需要再次确认。", + "rename": "另存为新 ID", + "renameHint": "保留当前本地工作流,并以新的 ID 创建副本。", + "newWorkflowId": "新的工作流 ID", + "newWorkflowIdPlaceholder": "例如:web-scan-basic-copy", + "continueOverwrite": "继续确认覆盖", + "confirmRename": "确认另存", + "confirmCreate": "确认创建", + "confirmComplete": "确认完成" + }, + "result": { + "created": "导入成功,已创建新的本地工作流。", + "overwritten": "导入成功,已覆盖本地工作流。", + "renamed": "导入成功,已按新 ID 创建本地工作流。", + "keptExisting": "已保留本地工作流,未写入任何更改。", + "skippedIdentical": "本地已存在相同内容,已跳过导入。", + "complete": "导入已完成。", + "completedAction": "导入已完成" + }, + "exportSelectSaved": "请先选择已保存的工作流后再导出。" + }, "connect": "连线", "connecting": "连线中", "deleteSelected": "删除选中", diff --git a/web/static/js/workflow-package-client.js b/web/static/js/workflow-package-client.js new file mode 100644 index 00000000..32cb0c66 --- /dev/null +++ b/web/static/js/workflow-package-client.js @@ -0,0 +1,117 @@ +(function (root, factory) { + const client = factory(root); + if (typeof module === 'object' && module.exports) module.exports = client; + if (root) root.WorkflowPackageClient = client; +})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) { + const ACTIONS_BY_CONFLICT = { + none: ['create'], + identical: ['keep_existing'], + id_conflict: ['keep_existing', 'overwrite', 'rename'] + }; + + class WorkflowPackageError extends Error { + constructor(code, message, details) { + super(message || code || 'WFPKG_REQUEST_FAILED'); + this.name = 'WorkflowPackageError'; + this.code = code || ''; + this.details = details || {}; + } + } + + function allowedActions(conflictState) { + return (ACTIONS_BY_CONFLICT[conflictState] || []).slice(); + } + + function buildImportRequest(input) { + const data = input || {}; + const inspectionId = String(data.inspectionId || '').trim(); + const action = String(data.action || '').trim(); + const newWorkflowId = String(data.newWorkflowId || '').trim(); + const confirmOverwrite = data.confirmOverwrite === true; + if (!inspectionId) throw new WorkflowPackageError('WFPKG_INSPECTION_REQUIRED', '缺少预检记录'); + if (!['create', 'keep_existing', 'overwrite', 'rename'].includes(action)) { + throw new WorkflowPackageError('WFPKG_INVALID_ACTION', '导入处理方式无效'); + } + if (action === 'rename' && !newWorkflowId) { + throw new WorkflowPackageError('WFPKG_INVALID_RENAME_ID', '请输入新的工作流 ID'); + } + if (action !== 'rename' && newWorkflowId) { + throw new WorkflowPackageError('WFPKG_INVALID_ACTION', '当前处理方式不允许填写新的工作流 ID'); + } + if (action === 'overwrite' && !confirmOverwrite) { + throw new WorkflowPackageError('WFPKG_OVERWRITE_CONFIRMATION_REQUIRED', '请确认覆盖本地工作流'); + } + return { + inspection_id: inspectionId, + resolution: { + action: action, + new_workflow_id: action === 'rename' ? newWorkflowId : '' + }, + confirm_overwrite: action === 'overwrite' + }; + } + + function createIdempotencyKey() { + const cryptoApi = root && root.crypto; + if (!cryptoApi || typeof cryptoApi.randomUUID !== 'function') { + throw new WorkflowPackageError('WFPKG_IDEMPOTENCY_KEY_REQUIRED', '浏览器无法生成导入请求标识'); + } + return cryptoApi.randomUUID(); + } + + async function readApiError(response) { + const payload = await response.json().catch(function () { return {}; }); + const error = payload && payload.error ? payload.error : {}; + return { + code: error.code || '', + message: error.message || '', + details: error.details || {} + }; + } + + async function readJsonOrThrow(response) { + if (response.ok) return response.json(); + const error = await readApiError(response); + throw new WorkflowPackageError(error.code, error.message, error.details); + } + + async function createInspection(apiFetch, file) { + if (!file) throw new WorkflowPackageError('WFPKG_FILE_REQUIRED', '请选择本地图编排包'); + const body = new FormData(); + body.append('file', file, file.name || 'workflow.csapkg.zip'); + const response = await apiFetch('/api/workflow-package-inspections', { method: 'POST', body: body }); + return readJsonOrThrow(response); + } + + async function getInspection(apiFetch, inspectionId) { + const response = await apiFetch('/api/workflow-package-inspections/' + encodeURIComponent(inspectionId)); + return readJsonOrThrow(response); + } + + async function applyImport(apiFetch, request, idempotencyKey) { + if (!idempotencyKey) throw new WorkflowPackageError('WFPKG_IDEMPOTENCY_KEY_REQUIRED', '缺少导入请求标识'); + const response = await apiFetch('/api/workflow-package-imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, + body: JSON.stringify(request) + }); + return readJsonOrThrow(response); + } + + async function getImport(apiFetch, importId) { + const response = await apiFetch('/api/workflow-package-imports/' + encodeURIComponent(importId)); + return readJsonOrThrow(response); + } + + return { + WorkflowPackageError: WorkflowPackageError, + allowedActions: allowedActions, + buildImportRequest: buildImportRequest, + createIdempotencyKey: createIdempotencyKey, + readApiError: readApiError, + createInspection: createInspection, + getInspection: getInspection, + applyImport: applyImport, + getImport: getImport + }; +}); diff --git a/web/static/js/workflow-package-client.test.cjs b/web/static/js/workflow-package-client.test.cjs new file mode 100644 index 00000000..ec35f92b --- /dev/null +++ b/web/static/js/workflow-package-client.test.cjs @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const client = require('./workflow-package-client.js'); + +test('id 冲突默认仅允许保留、本地覆盖或另存', () => { + assert.deepEqual(client.allowedActions('id_conflict'), ['keep_existing', 'overwrite', 'rename']); + assert.deepEqual(client.allowedActions('identical'), ['keep_existing']); + assert.deepEqual(client.allowedActions('none'), ['create']); +}); + +test('覆盖导入请求必须带确认和空的新 ID', () => { + assert.deepEqual(client.buildImportRequest({ + inspectionId: 'wpi_1', + action: 'overwrite', + newWorkflowId: '', + confirmOverwrite: true + }), { + inspection_id: 'wpi_1', + resolution: { action: 'overwrite', new_workflow_id: '' }, + confirm_overwrite: true + }); +}); + +test('预检以 multipart file 请求并保留 API 返回体', async () => { + let observed; + const apiFetch = async (url, options) => { + observed = { url, options }; + return new Response(JSON.stringify({ inspection: { id: 'wpi_1', status: 'ready' } }), { status: 201 }); + }; + const data = await client.createInspection(apiFetch, new Blob(['zip'])); + assert.equal(observed.url, '/api/workflow-package-inspections'); + assert.equal(observed.options.method, 'POST'); + assert.equal(observed.options.body instanceof FormData, true); + assert.equal(data.inspection.id, 'wpi_1'); +}); + +test('导入以稳定幂等键发送规范请求体', async () => { + let observed; + const apiFetch = async (url, options) => { + observed = { url, options }; + return new Response(JSON.stringify({ import: { id: 'wpii_1', status: 'succeeded' } }), { status: 201 }); + }; + const request = client.buildImportRequest({ + inspectionId: 'wpi_1', action: 'keep_existing', newWorkflowId: '', confirmOverwrite: false + }); + const data = await client.applyImport(apiFetch, request, 'f1d13d55-c35d-4693-b507-d2e2ea5703f9'); + assert.equal(observed.url, '/api/workflow-package-imports'); + assert.equal(observed.options.headers['Idempotency-Key'], 'f1d13d55-c35d-4693-b507-d2e2ea5703f9'); + assert.deepEqual(JSON.parse(observed.options.body), request); + assert.equal(data.import.id, 'wpii_1'); +}); diff --git a/web/static/js/workflow-package-ui.test.cjs b/web/static/js/workflow-package-ui.test.cjs new file mode 100644 index 00000000..5ed4736a --- /dev/null +++ b/web/static/js/workflow-package-ui.test.cjs @@ -0,0 +1,70 @@ +const fs = require('node:fs'); +const test = require('node:test'); +const assert = require('node:assert/strict'); + +test('图编排提供导入、导出和覆盖确认容器', () => { + const html = fs.readFileSync('web/templates/index.html', 'utf8'); + const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8')); + assert.match(html, /onclick="openWorkflowPackageImportModal\(\)"/); + assert.match(html, /onclick="exportCurrentWorkflowPackage\(\)"/); + assert.match(html, /id="workflow-package-import-modal"/); + assert.match(html, /id="workflow-package-overwrite-modal"/); + assert.equal(zh.workflows.package.importLocal, '导入本地包'); +}); + +test('工作流脚本调用包契约的全部端点与冲突错误码', () => { + const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + const client = fs.readFileSync('web/static/js/workflow-package-client.js', 'utf8'); + assert.match(workflows, /\/api\/workflows\/\$\{encodeURIComponent\(id\)\}\/package/); + assert.match(client, /\/api\/workflow-package-inspections/); + assert.match(client, /\/api\/workflow-package-imports/); + assert.match(workflows, /WFPKG_CONFLICT_CHANGED/); + assert.match(workflows, /WFPKG_INSPECTION_EXPIRED/); +}); + +test('预检无效包的契约错误码都有中文状态分支', () => { + const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + [ + 'WFPKG_INVALID_ARCHIVE', + 'WFPKG_UNSUPPORTED_FORMAT', + 'WFPKG_INVALID_MANIFEST', + 'WFPKG_CHECKSUM_MISMATCH', + 'WFPKG_MULTIPLE_WORKFLOWS', + 'WFPKG_WORKFLOW_INVALID' + ].forEach((code) => assert.match(workflows, new RegExp(code))); +}); + +test('导入请求标识生成失败会进入中文错误处理', () => { + const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + assert.match(workflows, /async function performWorkflowPackageImport\(request\)[\s\S]*?try\s*\{[\s\S]*?client\.createIdempotencyKey\(\)[\s\S]*?catch \(error\) \{\s*displayWorkflowPackageError\(error\)/); +}); + +test('工作流包动态状态同时提供中文和英文词条', () => { + const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8')); + const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8')); + const keys = [ + ['errors', 'invalidArchive'], + ['conflict', 'idConflict'], + ['summary', 'workflowName'], + ['resolution', 'keepExisting'], + ['result', 'overwritten'] + ]; + keys.forEach(([section, key]) => { + assert.equal(typeof zh.workflows.package[section][key], 'string'); + assert.equal(typeof en.workflows.package[section][key], 'string'); + }); +}); + +test('语言切换会刷新工作流包弹窗及其动态状态', () => { + const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + assert.match(workflows, /function refreshWorkflowsI18n\(\)[\s\S]*?workflow-package-import-modal[\s\S]*?workflow-package-overwrite-modal[\s\S]*?renderWorkflowPackageInspection\(\)[\s\S]*?renderWorkflowPackageResolution\(\)/); +}); + +test('每次打开导入弹窗都会开始新的导入会话', () => { + const workflows = fs.readFileSync('web/static/js/workflows.js', 'utf8'); + const start = workflows.indexOf('window.openWorkflowPackageImportModal = async function ()'); + const end = workflows.indexOf('window.closeWorkflowPackageImportModal = function ()', start); + const openHandler = workflows.slice(start, end); + assert.match(openHandler, /resetWorkflowPackageImport\(\);/); + assert.doesNotMatch(openHandler, /restoreWorkflowPackageState\(\)/); +}); diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index 274a429b..f693cf72 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -26,6 +26,18 @@ const WORKFLOW_TOOL_SELECT_ID = 'workflow-tool-name'; let workflowToolSelectRegistry = null; let workflowToolSelectDocBound = false; + const workflowPackageState = { + file: null, + inspection: null, + importRecord: null, + resolutionAction: '', + newWorkflowId: '', + riskChoicesVisible: false, + idempotencyKey: '', + requestSignature: '' + }; + const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id'; + const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id'; const KNOWN_NODE_LABELS = { start: ['开始', 'Start'], @@ -1863,6 +1875,427 @@ } }; + function workflowPackageClient() { + return window.WorkflowPackageClient || null; + } + + function workflowPackageText(key, fallback, opts) { + const translated = _t(key, opts); + return translated === key ? fallback : translated; + } + + function workflowPackageStorageGet(key) { + try { return window.sessionStorage.getItem(key) || ''; } catch (_) { return ''; } + } + + function workflowPackageStorageSet(key, value) { + try { + if (value) window.sessionStorage.setItem(key, value); + else window.sessionStorage.removeItem(key); + } catch (_) { /* session restore is best effort */ } + } + + function workflowPackageInspectionEl() { + return document.getElementById('workflow-package-inspection'); + } + + function workflowPackageResolutionEl() { + return document.getElementById('workflow-package-resolution'); + } + + function workflowPackageSubmitBtn() { + return document.getElementById('workflow-package-submit-btn'); + } + + function workflowPackageSetStep(step) { + const inspectionStep = document.getElementById('workflow-package-step-inspection'); + const importStep = document.getElementById('workflow-package-step-import'); + if (inspectionStep) inspectionStep.classList.toggle('is-active', step === 'inspection'); + if (importStep) importStep.classList.toggle('is-active', step === 'import'); + } + + function workflowPackageSetStatus(target, message, type) { + if (!target) return; + target.innerHTML = `
${esc(message)}
`; + } + + function workflowPackageErrorMessage(error) { + const code = error && error.code ? error.code : ''; + const errorKeys = { + WFPKG_FILE_REQUIRED: 'fileRequired', + WFPKG_FILE_TOO_LARGE: 'fileTooLarge', + WFPKG_INVALID_ARCHIVE: 'invalidArchive', + WFPKG_UNSUPPORTED_FORMAT: 'unsupportedFormat', + WFPKG_INVALID_MANIFEST: 'invalidManifest', + WFPKG_CHECKSUM_MISMATCH: 'checksumMismatch', + WFPKG_MULTIPLE_WORKFLOWS: 'multipleWorkflows', + WFPKG_WORKFLOW_INVALID: 'workflowInvalid', + WFPKG_INSPECTION_NOT_FOUND: 'inspectionNotFound', + WFPKG_INSPECTION_EXPIRED: 'inspectionExpired', + WFPKG_INSPECTION_CONSUMED: 'inspectionConsumed', + WFPKG_CONFLICT_CHANGED: 'conflictChanged', + WFPKG_ID_CONFLICT: 'idConflict', + WFPKG_INVALID_ACTION: 'invalidAction', + WFPKG_INVALID_RENAME_ID: 'invalidRenameId', + WFPKG_OVERWRITE_CONFIRMATION_REQUIRED: 'overwriteConfirmationRequired', + WFPKG_IDEMPOTENCY_KEY_REQUIRED: 'idempotencyKeyRequired', + WFPKG_IDEMPOTENCY_KEY_REUSED: 'idempotencyKeyReused', + WFPKG_IMPORT_FAILED: 'importFailed', + WFPKG_EXPORT_FAILED: 'exportFailed', + WFPKG_WORKFLOW_NOT_FOUND: 'workflowNotFound' + }; + const key = errorKeys[code]; + if (key) return workflowPackageText('workflows.package.errors.' + key, '操作未完成,请稍后重试。'); + return (error && error.message) || workflowPackageText('workflows.package.errors.generic', '操作未完成,请稍后重试。'); + } + + function workflowPackageConflictCopy(conflict) { + const state = (conflict && conflict.state) || 'none'; + if (state === 'identical') return { message: workflowPackageText('workflows.package.conflict.identical', '检测到同 ID 且内容相同的本地工作流;本次将跳过导入。'), type: 'success' }; + if (state === 'id_conflict') return { message: workflowPackageText('workflows.package.conflict.idConflict', '检测到同 ID 但内容不同的本地工作流。默认保留本地版本。'), type: 'warning' }; + return { message: workflowPackageText('workflows.package.conflict.none', '未发现同 ID 的本地工作流,可创建导入。'), type: 'success' }; + } + + function renderWorkflowPackageInspection() { + const target = workflowPackageInspectionEl(); + const inspection = workflowPackageState.inspection; + if (!inspection) { + if (target) target.innerHTML = ''; + return; + } + const workflow = inspection.workflow || {}; + const conflict = inspection.conflict || {}; + const conflictCopy = workflowPackageConflictCopy(conflict); + const rows = [ + [workflowPackageText('workflows.package.summary.workflowName', '工作流名称'), workflow.name || workflowPackageText('workflows.package.summary.unnamedWorkflow', '未命名工作流')], + [workflowPackageText('workflows.package.summary.sourceId', '源工作流 ID'), workflow.source_id || '—'], + [workflowPackageText('workflows.package.summary.sourceRevision', '源版本'), workflow.source_revision || '—'], + [workflowPackageText('workflows.package.summary.graphSize', '图规模'), workflowPackageText('workflows.package.summary.graphSizeValue', `${workflow.node_count || 0} 个节点 · ${workflow.edge_count || 0} 条连线`, { nodes: workflow.node_count || 0, edges: workflow.edge_count || 0 })], + [workflowPackageText('workflows.package.summary.contentHash', '内容摘要'), workflow.content_hash || '—'], + [workflowPackageText('workflows.package.summary.expiresAt', '预检有效期'), inspection.expires_at || '—'] + ]; + if (!target) return; + target.innerHTML = `
${esc(conflictCopy.message)}
` + rows.map(function (row) { + return `
${esc(row[0])}${esc(row[1])}
`; + }).join(''); + } + + function workflowPackageResolutionCard(action, title, description, selected) { + return ``; + } + + function renderWorkflowPackageResolution(message, type) { + const target = workflowPackageResolutionEl(); + const submit = workflowPackageSubmitBtn(); + const inspection = workflowPackageState.inspection; + if (!inspection) { + if (target) workflowPackageSetStatus(target, message || workflowPackageText('workflows.package.resolution.needInspection', '请先选择本地包并完成预检。'), type || ''); + if (submit) submit.disabled = true; + return; + } + const client = workflowPackageClient(); + const conflictState = (inspection.conflict && inspection.conflict.state) || 'none'; + const allowed = client ? client.allowedActions(conflictState) : []; + if (allowed.indexOf(workflowPackageState.resolutionAction) === -1) { + workflowPackageState.resolutionAction = conflictState === 'none' ? 'create' : 'keep_existing'; + } + const action = workflowPackageState.resolutionAction; + let html = ''; + if (message) html += `
${esc(message)}
`; + if (conflictState === 'none') { + html += `
${esc(workflowPackageText('workflows.package.resolution.createHint', '导入后会创建一个新的本地工作流。'))}
`; + } else if (conflictState === 'identical') { + html += `
${esc(workflowPackageText('workflows.package.resolution.identicalHint', '内容已经存在,无需重复导入。'))}
`; + } else { + html += workflowPackageResolutionCard('keep_existing', workflowPackageText('workflows.package.resolution.keepExisting', '保留本地版本'), workflowPackageText('workflows.package.resolution.keepExistingHint', '不修改当前本地工作流;导入记录会保留。'), action === 'keep_existing'); + if (!workflowPackageState.riskChoicesVisible) { + html += ``; + } else { + html += workflowPackageResolutionCard('overwrite', workflowPackageText('workflows.package.resolution.overwrite', '覆盖本地版本'), workflowPackageText('workflows.package.resolution.overwriteHint', '替换名称、描述、图定义和启用状态;需要再次确认。'), action === 'overwrite'); + html += workflowPackageResolutionCard('rename', workflowPackageText('workflows.package.resolution.rename', '另存为新 ID'), workflowPackageText('workflows.package.resolution.renameHint', '保留当前本地工作流,并以新的 ID 创建副本。'), action === 'rename'); + if (action === 'rename') { + html += ``; + } + } + } + if (target) target.innerHTML = html; + if (submit) { + const renameIncomplete = action === 'rename' && !workflowPackageState.newWorkflowId.trim(); + submit.disabled = !action || renameIncomplete; + if (action === 'overwrite') submit.textContent = workflowPackageText('workflows.package.resolution.continueOverwrite', '继续确认覆盖'); + else if (action === 'rename') submit.textContent = workflowPackageText('workflows.package.resolution.confirmRename', '确认另存'); + else submit.textContent = action === 'create' ? workflowPackageText('workflows.package.resolution.confirmCreate', '确认创建') : workflowPackageText('workflows.package.resolution.confirmComplete', '确认完成'); + } + workflowPackageSetStep('import'); + } + + function resetWorkflowPackageImport(options) { + const keepInspection = options && options.keepInspection; + workflowPackageState.file = null; + workflowPackageState.importRecord = null; + workflowPackageState.newWorkflowId = ''; + workflowPackageState.riskChoicesVisible = false; + workflowPackageState.idempotencyKey = ''; + workflowPackageState.requestSignature = ''; + if (!keepInspection) { + workflowPackageState.inspection = null; + workflowPackageState.resolutionAction = ''; + workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, ''); + } + workflowPackageStorageSet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY, ''); + const input = document.getElementById('workflow-package-file-input'); + const name = document.getElementById('workflow-package-file-name'); + if (input) input.value = ''; + if (name) name.textContent = workflowPackageText('workflows.package.noFile', '未选择文件'); + } + + function displayWorkflowPackageError(error) { + const message = workflowPackageErrorMessage(error); + if (error && (error.code === 'WFPKG_INSPECTION_EXPIRED' || error.code === 'WFPKG_CONFLICT_CHANGED' || error.code === 'WFPKG_INSPECTION_CONSUMED')) { + workflowPackageState.inspection = null; + workflowPackageState.resolutionAction = ''; + workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, ''); + workflowPackageSetStep('inspection'); + renderWorkflowPackageInspection(); + } + renderWorkflowPackageResolution(message, 'warning'); + if (typeof showNotification === 'function') showNotification(message, 'error'); + } + + function renderWorkflowPackageImportResult(importRecord) { + const result = importRecord && importRecord.result; + const resultKeys = { + created: 'created', + overwritten: 'overwritten', + renamed: 'renamed', + kept_existing: 'keptExisting', + skipped_identical: 'skippedIdentical' + }; + const resultKey = resultKeys[result]; + const message = resultKey + ? workflowPackageText('workflows.package.result.' + resultKey, '导入已完成。') + : workflowPackageText('workflows.package.result.complete', '导入已完成。'); + renderWorkflowPackageResolution(message, result === 'kept_existing' || result === 'skipped_identical' ? '' : 'success'); + const submit = workflowPackageSubmitBtn(); + if (submit) { + submit.disabled = true; + submit.textContent = workflowPackageText('workflows.package.result.completedAction', '导入已完成'); + } + } + + async function restoreWorkflowPackageState() { + const client = workflowPackageClient(); + if (!client || typeof apiFetch !== 'function') return; + const importId = workflowPackageStorageGet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY); + if (importId) { + try { + const data = await client.getImport(apiFetch, importId); + workflowPackageState.importRecord = data.import || data; + renderWorkflowPackageImportResult(workflowPackageState.importRecord); + return; + } catch (_) { + workflowPackageStorageSet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY, ''); + } + } + const inspectionId = workflowPackageStorageGet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY); + if (!inspectionId) return; + try { + const data = await client.getInspection(apiFetch, inspectionId); + workflowPackageState.inspection = data.inspection || data; + renderWorkflowPackageInspection(); + renderWorkflowPackageResolution(); + } catch (error) { + workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, ''); + displayWorkflowPackageError(error); + } + } + + window.openWorkflowPackageImportModal = async function () { + if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return; + resetWorkflowPackageImport(); + renderWorkflowPackageInspection(); + renderWorkflowPackageResolution(); + workflowPackageSetStep('inspection'); + if (typeof openAppModal === 'function') openAppModal('workflow-package-import-modal', { focusEl: document.getElementById('workflow-package-inspect-btn') }); + if (typeof window.applyTranslations === 'function') window.applyTranslations(document.getElementById('workflow-package-import-modal')); + }; + + window.closeWorkflowPackageImportModal = function () { + if (typeof closeAppModal === 'function') closeAppModal('workflow-package-import-modal'); + resetWorkflowPackageImport(); + }; + + window.onWorkflowPackageFileSelected = function () { + const input = document.getElementById('workflow-package-file-input'); + const name = document.getElementById('workflow-package-file-name'); + const file = input && input.files ? input.files[0] : null; + resetWorkflowPackageImport(); + workflowPackageState.file = file || null; + if (name) name.textContent = file ? file.name : workflowPackageText('workflows.package.noFile', '未选择文件'); + renderWorkflowPackageResolution(); + workflowPackageSetStep('inspection'); + }; + + window.inspectWorkflowPackage = async function () { + if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return; + const client = workflowPackageClient(); + const button = document.getElementById('workflow-package-inspect-btn'); + if (!client || typeof apiFetch !== 'function') { + displayWorkflowPackageError({ code: 'WFPKG_REQUEST_FAILED', message: workflowPackageText('workflows.package.errors.serviceUnavailable', '导入服务尚未准备好,请刷新页面后重试。') }); + return; + } + if (button) button.disabled = true; + try { + const data = await client.createInspection(apiFetch, workflowPackageState.file); + workflowPackageState.inspection = data.inspection || data; + workflowPackageState.importRecord = null; + workflowPackageState.riskChoicesVisible = false; + workflowPackageState.newWorkflowId = ''; + workflowPackageState.resolutionAction = ''; + workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, workflowPackageState.inspection.id || ''); + renderWorkflowPackageInspection(); + renderWorkflowPackageResolution(); + } catch (error) { + displayWorkflowPackageError(error); + } finally { + if (button) button.disabled = false; + } + }; + + window.revealWorkflowPackageRiskChoices = function () { + workflowPackageState.riskChoicesVisible = true; + renderWorkflowPackageResolution(); + }; + + window.selectWorkflowPackageResolution = function (action) { + const client = workflowPackageClient(); + const conflict = workflowPackageState.inspection && workflowPackageState.inspection.conflict; + const allowed = client ? client.allowedActions((conflict && conflict.state) || 'none') : []; + if (allowed.indexOf(action) === -1) return; + workflowPackageState.resolutionAction = action; + if (action !== 'rename') workflowPackageState.newWorkflowId = ''; + renderWorkflowPackageResolution(); + }; + + window.updateWorkflowPackageRenameId = function () { + const input = document.getElementById('workflow-package-new-id'); + workflowPackageState.newWorkflowId = input ? input.value : ''; + const submit = workflowPackageSubmitBtn(); + if (submit) submit.disabled = !workflowPackageState.newWorkflowId.trim(); + }; + + async function performWorkflowPackageImport(request) { + const client = workflowPackageClient(); + const submit = workflowPackageSubmitBtn(); + if (submit) submit.disabled = true; + try { + const signature = JSON.stringify(request); + if (!workflowPackageState.idempotencyKey || workflowPackageState.requestSignature !== signature) { + workflowPackageState.idempotencyKey = client.createIdempotencyKey(); + workflowPackageState.requestSignature = signature; + } + const data = await client.applyImport(apiFetch, request, workflowPackageState.idempotencyKey); + workflowPackageState.importRecord = data.import || data; + workflowPackageStorageSet(WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY, workflowPackageState.importRecord.id || ''); + workflowPackageStorageSet(WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY, ''); + workflowPackageState.inspection = null; + renderWorkflowPackageImportResult(workflowPackageState.importRecord); + if (typeof window.refreshWorkflows === 'function') await window.refreshWorkflows(); + } catch (error) { + displayWorkflowPackageError(error); + } finally { + if (submit && !workflowPackageState.importRecord) submit.disabled = false; + } + } + + window.submitWorkflowPackageImport = async function () { + const client = workflowPackageClient(); + if (!client || !workflowPackageState.inspection) return; + if (workflowPackageState.resolutionAction === 'overwrite') { + const checkbox = document.getElementById('workflow-package-overwrite-confirm'); + const submit = document.getElementById('workflow-package-overwrite-submit-btn'); + if (checkbox) checkbox.checked = false; + if (submit) submit.disabled = true; + if (typeof openAppModal === 'function') openAppModal('workflow-package-overwrite-modal', { focusEl: checkbox }); + return; + } + let request; + try { + request = client.buildImportRequest({ + inspectionId: workflowPackageState.inspection.id, + action: workflowPackageState.resolutionAction, + newWorkflowId: workflowPackageState.newWorkflowId, + confirmOverwrite: false + }); + } catch (error) { + displayWorkflowPackageError(error); + return; + } + await performWorkflowPackageImport(request); + }; + + window.closeWorkflowPackageOverwriteModal = function () { + if (typeof closeAppModal === 'function') closeAppModal('workflow-package-overwrite-modal'); + }; + + window.updateWorkflowPackageOverwriteConfirmation = function () { + const checkbox = document.getElementById('workflow-package-overwrite-confirm'); + const submit = document.getElementById('workflow-package-overwrite-submit-btn'); + if (submit) submit.disabled = !checkbox || !checkbox.checked; + }; + + window.confirmWorkflowPackageOverwrite = async function () { + const checkbox = document.getElementById('workflow-package-overwrite-confirm'); + const client = workflowPackageClient(); + if (!client || !checkbox || !checkbox.checked || !workflowPackageState.inspection) return; + try { + const request = client.buildImportRequest({ + inspectionId: workflowPackageState.inspection.id, + action: 'overwrite', + newWorkflowId: '', + confirmOverwrite: true + }); + window.closeWorkflowPackageOverwriteModal(); + await performWorkflowPackageImport(request); + } catch (error) { + displayWorkflowPackageError(error); + } + }; + + window.exportCurrentWorkflowPackage = async function () { + if (typeof requirePermission === 'function' && !requirePermission('workflow:read')) return; + const id = currentWorkflowId || readWorkflowMetaFromForm().id; + if (!id) { + if (typeof showNotification === 'function') showNotification(workflowPackageText('workflows.package.exportSelectSaved', '请先选择已保存的工作流后再导出。'), 'warning'); + return; + } + try { + const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}/package`, { method: 'GET' }); + if (!response.ok) { + const error = workflowPackageClient() ? await workflowPackageClient().readApiError(response) : {}; + throw error; + } + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename\*?=(?:UTF-8''|\")?([^;\"]+)/i); + const filename = match && match[1] ? decodeURIComponent(match[1].trim()) : `${id}.csapkg.zip`; + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(function () { URL.revokeObjectURL(url); }, 0); + } catch (error) { + const message = workflowPackageErrorMessage(error); + if (typeof showNotification === 'function') showNotification(message, 'error'); + } + }; + function refreshCanvasLabels() { if (!cy) return; cy.nodes().forEach(function (node) { @@ -1888,6 +2321,10 @@ if (page && typeof window.applyTranslations === 'function') { window.applyTranslations(page); } + ['workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) { + const modal = document.getElementById(id); + if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal); + }); const connectBtn = document.getElementById('workflow-connect-btn'); if (connectBtn) { connectBtn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect'); @@ -1903,6 +2340,12 @@ if (typeof loadWorkflowOptionsForRoleModal === 'function') { loadWorkflowOptionsForRoleModal(); } + if (workflowPackageState.importRecord) { + renderWorkflowPackageImportResult(workflowPackageState.importRecord); + } else { + renderWorkflowPackageInspection(); + renderWorkflowPackageResolution(); + } } document.addEventListener('languagechange', function () { diff --git a/web/templates/index.html b/web/templates/index.html index ac8c2963..1ea8a076 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2620,6 +2620,7 @@

图编排

+
@@ -2661,6 +2662,7 @@ + @@ -4890,6 +4892,57 @@ + +