feat(工作流):支持本地图编排策略包导入导出 (#195)

* 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”>
This commit is contained in:
chenchen
2026-07-14 11:28:37 +08:00
committed by GitHub
parent 2e5c1ff286
commit acfacfe1b3
24 changed files with 3154 additions and 3 deletions
@@ -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"`.
@@ -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` | 无资源 IDdetail 仅含错误码与包 hash |
| 应用成功 | `workflow_package` | `import` | `workflow/{resulting_workflow_id}` |
| 应用失败 | `workflow_package` | `import` | `inspection/{id}` |
## 6. 前后端并行边界
前端可依据本文直接完成:下载按钮、文件上传、预检结果页、冲突动作选择、`confirm_overwrite` 二次确认、导入结果页和错误码国际化。
后端可依据本文直接完成:路由、Handler、包解析服务、SQLite 迁移、事务、RBAC 映射、审计和单元/集成测试。
前端不得自行解析 Zip、计算最终冲突结论或直接提交工作流 JSON;后端是 Manifest、哈希、图校验、冲突复查和导入结果的唯一权威。
@@ -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 之后的独立子项目,不进入本期实现。
+17
View File
@@ -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)
+30 -3
View File
@@ -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)
+286
View File
@@ -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<? AND NOT EXISTS (SELECT 1 FROM workflow_package_imports i WHERE i.inspection_id=workflow_package_inspections.id)`, now.Add(-24*time.Hour)); err != nil {
return err
}
_, err := db.Exec(`DELETE FROM workflow_package_imports WHERE created_at<?`, now.AddDate(0, 0, -90))
return err
}
@@ -0,0 +1,74 @@
package database
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestWorkflowPackageApplyOverwriteIsTransactionalAndIdempotent(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
current := &WorkflowDefinition{ID: "wf-1", Name: "Local", Version: 12, GraphJSON: `{"nodes":[]}`, Enabled: true}
if err := db.UpsertWorkflowDefinition(current); err != nil {
t.Fatal(err)
}
current, _ = db.GetWorkflowDefinition("wf-1")
content, graph := workflowDefinitionPackageHashes(current)
payload, _ := json.Marshal(map[string]any{"id": "wf-1", "name": "Imported", "description": "new", "version": 18, "graph_json": `{"nodes":[]}`, "enabled": false})
now := time.Now().UTC()
inspection := &WorkflowPackageInspection{ID: "wpi_test", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-1", SourceRevision: 18, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-1", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
t.Fatal(err)
}
req := WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-1", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true}
imp, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
if err != nil || replayed || imp.Result != "overwritten" {
t.Fatalf("apply = %#v replay=%v err=%v", imp, replayed, err)
}
updated, _ := db.GetWorkflowDefinition("wf-1")
if updated.Version != 13 || updated.Name != "Imported" || updated.Enabled {
t.Fatalf("updated workflow = %#v", updated)
}
replay, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
if err != nil || !replayed || replay.ID != imp.ID {
t.Fatalf("replay = %#v replay=%v err=%v", replay, replayed, err)
}
gotInspection, err := db.GetWorkflowPackageInspection(inspection.ID, "user-1")
if err != nil || gotInspection.Status != "consumed" {
t.Fatalf("inspection=%#v err=%v", gotInspection, err)
}
}
func TestWorkflowPackageApplyRejectsChangedConflictSnapshot(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package-conflict.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Local", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
t.Fatal(err)
}
local, _ := db.GetWorkflowDefinition("wf-2")
content, graph := workflowDefinitionPackageHashes(local)
payload, _ := json.Marshal(map[string]any{"id": "wf-2", "name": "Imported", "version": 2, "graph_json": `{"nodes":[]}`, "enabled": true})
now := time.Now().UTC()
inspection := &WorkflowPackageInspection{ID: "wpi_changed", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-2", SourceRevision: 2, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-2", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
t.Fatal(err)
}
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Changed", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
t.Fatal(err)
}
_, _, err = db.ApplyWorkflowPackageImport(context.Background(), WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-2", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true})
if e, ok := err.(*WorkflowPackageStoreError); !ok || e.Code != "WFPKG_CONFLICT_CHANGED" {
t.Fatalf("err=%v", err)
}
}
+319
View File
@@ -0,0 +1,319 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
workflowrunner "cyberstrike-ai/internal/workflow"
workflowpkg "cyberstrike-ai/internal/workflow/package"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type workflowPackageResolution struct {
Action string `json:"action"`
NewWorkflowID string `json:"new_workflow_id"`
}
type workflowPackageImportRequest struct {
InspectionID string `json:"inspection_id"`
Resolution workflowPackageResolution `json:"resolution"`
ConfirmOverwrite bool `json:"confirm_overwrite"`
}
func (h *WorkflowHandler) ExportPackage(c *gin.Context) {
wf, err := h.db.GetWorkflowDefinition(c.Param("id"))
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
return
}
if wf == nil {
writeWorkflowPackageError(c, http.StatusNotFound, "WFPKG_WORKFLOW_NOT_FOUND", "工作流不存在", nil)
return
}
pkg, meta, err := workflowpkg.Export(workflowPackageDocument(wf))
if err != nil {
writeWorkflowPackageError(c, http.StatusInternalServerError, "WFPKG_EXPORT_FAILED", "导出工作流包失败", nil)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": meta.FileName}))
c.Header("ETag", `"`+meta.PackageHash+`"`)
c.Header("X-Workflow-Package-SHA256", meta.PackageHash)
if h.audit != nil {
h.audit.RecordOK(c, "workflow_package", "export", "导出工作流包", "workflow", wf.ID, map[string]interface{}{"package_hash": meta.PackageHash})
}
c.Data(http.StatusOK, "application/zip", pkg)
}
func (h *WorkflowHandler) CreatePackageInspection(c *gin.Context) {
session, ok := security.CurrentSession(c)
if !ok || strings.TrimSpace(session.UserID) == "" {
writeWorkflowPackageError(c, http.StatusUnauthorized, "WFPKG_INSPECTION_NOT_FOUND", "未授权访问", nil)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, workflowpkg.MaxArchiveBytes+1)
file, _, err := c.Request.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制", nil)
return
}
writeWorkflowPackageError(c, http.StatusUnprocessableEntity, "WFPKG_FILE_REQUIRED", "必须上传工作流包文件", nil)
return
}
defer file.Close()
archive, err := io.ReadAll(io.LimitReader(file, workflowpkg.MaxArchiveBytes+1))
if err != nil || len(archive) > 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
}
+78
View File
@@ -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)
}
}
+7
View File
@@ -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"
}
@@ -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")
}
}
+98
View File
@@ -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
}
@@ -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)
}
}
+225
View File
@@ -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
}
+154
View File
@@ -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()
}
+78
View File
@@ -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)
}
+234
View File
@@ -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;
+87
View File
@@ -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",
+87
View File
@@ -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": "删除选中",
+117
View File
@@ -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
};
});
@@ -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');
});
@@ -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\(\)/);
});
+443
View File
@@ -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 = `<div class="workflow-package-status${type ? ' is-' + esc(type) : ''}">${esc(message)}</div>`;
}
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 = `<div class="workflow-package-status is-${conflictCopy.type}">${esc(conflictCopy.message)}</div>` + rows.map(function (row) {
return `<div class="workflow-package-summary-row"><span>${esc(row[0])}</span><code title="${esc(row[1])}">${esc(row[1])}</code></div>`;
}).join('');
}
function workflowPackageResolutionCard(action, title, description, selected) {
return `<label class="workflow-package-choice${selected ? ' is-selected' : ''}">
<input type="radio" name="workflow-package-resolution-action" value="${esc(action)}" ${selected ? 'checked' : ''} onchange="selectWorkflowPackageResolution('${esc(action)}')">
<span><strong>${esc(title)}</strong><small>${esc(description)}</small></span>
</label>`;
}
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 += `<div class="workflow-package-status${type ? ' is-' + esc(type) : ''}">${esc(message)}</div>`;
if (conflictState === 'none') {
html += `<div class="workflow-package-status is-success">${esc(workflowPackageText('workflows.package.resolution.createHint', '导入后会创建一个新的本地工作流。'))}</div>`;
} else if (conflictState === 'identical') {
html += `<div class="workflow-package-status is-success">${esc(workflowPackageText('workflows.package.resolution.identicalHint', '内容已经存在,无需重复导入。'))}</div>`;
} else {
html += workflowPackageResolutionCard('keep_existing', workflowPackageText('workflows.package.resolution.keepExisting', '保留本地版本'), workflowPackageText('workflows.package.resolution.keepExistingHint', '不修改当前本地工作流;导入记录会保留。'), action === 'keep_existing');
if (!workflowPackageState.riskChoicesVisible) {
html += `<button type="button" class="workflow-package-reveal" onclick="revealWorkflowPackageRiskChoices()">${esc(workflowPackageText('workflows.package.resolution.revealRiskChoices', '更改处理方式'))}</button>`;
} 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 += `<label class="workflow-package-rename-field">${esc(workflowPackageText('workflows.package.resolution.newWorkflowId', '新的工作流 ID'))}<input id="workflow-package-new-id" class="form-input" type="text" value="${esc(workflowPackageState.newWorkflowId)}" placeholder="${esc(workflowPackageText('workflows.package.resolution.newWorkflowIdPlaceholder', '例如:web-scan-basic-copy'))}" oninput="updateWorkflowPackageRenameId()" autocomplete="off"></label>`;
}
}
}
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 () {
+54
View File
@@ -2620,6 +2620,7 @@
<h2 data-i18n="workflows.title">图编排</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建图</button>
</div>
</div>
@@ -2661,6 +2662,7 @@
<button class="btn-secondary btn-small" data-require-permission="workflow:delete" type="button" onclick="deleteWorkflowSelection()" data-i18n="workflows.deleteSelected">删除选中</button>
<button class="btn-secondary btn-small" type="button" onclick="layoutWorkflowGraph()" data-i18n="workflows.autoLayout">自动布局</button>
<button class="btn-secondary btn-small" data-require-permission="workflow:execute" type="button" onclick="dryRunWorkflowDraft()" data-i18n="workflows.dryRun">试运行</button>
<button class="btn-secondary btn-small" data-require-permission="workflow:read" type="button" id="workflow-package-export-btn" onclick="exportCurrentWorkflowPackage()" data-i18n="workflows.package.export">导出</button>
<button class="btn-secondary btn-small" data-require-permission="workflow:delete" onclick="deleteCurrentWorkflow()" data-i18n="common.delete">删除</button>
<button class="btn-primary btn-small" data-require-permission="workflow:write" onclick="saveWorkflowDraft()" data-i18n="common.save">保存</button>
</div>
@@ -4890,6 +4892,57 @@
</div>
</div>
</div>
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
<div class="modal-content workflow-package-modal-content">
<div class="modal-header workflow-package-modal-header">
<div>
<h2 id="workflow-package-import-title" data-i18n="workflows.package.title">导入本地包</h2>
<p class="workflow-package-subtitle" data-i18n="workflows.package.preflightHint">预检不会修改本地工作流</p>
</div>
<span class="modal-close" onclick="closeWorkflowPackageImportModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label">&times;</span>
</div>
<div class="workflow-package-steps" aria-label="导入步骤" data-i18n="workflows.package.stepsLabel" data-i18n-attr="aria-label">
<span class="is-active" id="workflow-package-step-inspection"><b>1</b><span data-i18n="workflows.package.stepInspection">上传与预检</span></span>
<span id="workflow-package-step-import"><b>2</b><span data-i18n="workflows.package.stepImport">处理冲突与导入</span></span>
</div>
<div class="modal-body workflow-package-modal-body">
<section class="workflow-package-pane workflow-package-pane--inspection">
<div class="workflow-package-file-row">
<input id="workflow-package-file-input" type="file" accept=".csapkg.zip,application/zip" hidden onchange="onWorkflowPackageFileSelected()">
<button type="button" class="btn-secondary" onclick="document.getElementById('workflow-package-file-input').click()" data-i18n="workflows.package.selectFile">选择本地包</button>
<span id="workflow-package-file-name" class="workflow-package-file-name" data-i18n="workflows.package.noFile">未选择文件</span>
</div>
<p class="workflow-package-file-hint" data-i18n="workflows.package.fileHint">仅支持单个 .csapkg.zip,文件不得超过 10 MiB。</p>
<button id="workflow-package-inspect-btn" type="button" class="btn-primary" onclick="inspectWorkflowPackage()" data-i18n="workflows.package.inspect">上传并预检</button>
<div id="workflow-package-inspection" class="workflow-package-summary" aria-live="polite"></div>
</section>
<section class="workflow-package-pane workflow-package-pane--resolution">
<h3 data-i18n="workflows.package.resolutionTitle">处理方式</h3>
<div id="workflow-package-resolution" class="workflow-package-resolution" aria-live="polite"></div>
</section>
</div>
<div class="modal-footer workflow-package-modal-footer">
<button type="button" class="btn-secondary" onclick="closeWorkflowPackageImportModal()" data-i18n="common.cancel">取消</button>
<button id="workflow-package-submit-btn" type="button" class="btn-primary" onclick="submitWorkflowPackageImport()" disabled data-i18n="workflows.package.confirmImport">确认导入</button>
</div>
</div>
</div>
<div id="workflow-package-overwrite-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-overwrite-title">
<div class="modal-content workflow-package-overwrite-content">
<div class="modal-header">
<h2 id="workflow-package-overwrite-title" data-i18n="workflows.package.overwriteTitle">确认覆盖本地工作流?</h2>
<span class="modal-close" onclick="closeWorkflowPackageOverwriteModal()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label">&times;</span>
</div>
<div class="modal-body">
<p data-i18n="workflows.package.overwriteWarning">覆盖将替换名称、描述、图定义和启用状态。本期不提供回滚。</p>
<label class="workflow-package-confirm-check"><input id="workflow-package-overwrite-confirm" type="checkbox" onchange="updateWorkflowPackageOverwriteConfirmation()"> <span data-i18n="workflows.package.overwriteCheck">我已确认覆盖当前本地工作流</span></label>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeWorkflowPackageOverwriteModal()" data-i18n="common.cancel">取消</button>
<button id="workflow-package-overwrite-submit-btn" type="button" class="btn-primary" onclick="confirmWorkflowPackageOverwrite()" disabled data-i18n="workflows.package.overwriteConfirm">确认覆盖并导入</button>
</div>
</div>
</div>
<!-- 知识项编辑模态框 -->
<!-- Skill模态框 -->
<div id="skill-modal" class="modal">
@@ -5912,6 +5965,7 @@
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflow-package-client.js"></script>
<script src="/static/js/workflows.js"></script>
<script src="/static/js/roles.js"></script>
<script src="/static/js/rbac.js"></script>