# 工作流外部调用接口规范（External Workflow API Specification）

> 版本：v1.0
> 适用范围：第三方系统通过 HTTP 调用平台工作流，提交自定义输入、订阅节点级思考过程流、获取最终输出变量与工作空间产物。
> 文档基于现有 `/api/workflows/{id}/run`（SSE 同步流）与 `/api/workflows/{id}/runs/{runId}/download` 能力抽象出对外发布接口规范。

---

## 1. 设计目标

| 目标 | 说明 |
|---|---|
| 异步分离 | 提交运行与订阅流分离，客户端可延迟订阅、断线重连、批量并发 |
| 输入灵活 | 支持纯 JSON 变量、二进制文件、目录结构（保留层级路径）三种输入 |
| 全过程可观测 | 节点状态、思考流（thinking）、工具调用、错误事件全部以 SSE 事件推送 |
| 产物可获取 | 最终输出变量、工作目录 zip 均可通过稳定 URL 拉取 |
| 可认证 | 通过 `X-API-Key` 头部识别调用方，可绑定工作流白名单 |

---

## 2. 通用约定

### 2.1 基础路径

```
http(s)://<host>:<port>/api/v1
```

- 默认端口：`2438`（与平台后端一致，见 `application.yml: server.port`）
- 所有路径以 `/api/v1` 开头，与平台内部 `/api/workflows`、`/api/run-history` 隔离，便于独立鉴权

### 2.2 内容类型

| 场景 | Content-Type |
|---|---|
| 创建运行（仅 JSON 变量） | `application/json` |
| 创建运行（含文件/目录上传） | `multipart/form-data` |
| 订阅运行流（SSE） | 响应 `text/event-stream` |
| 查询运行结果 | 响应 `application/json` |
| 下载工作空间 | 响应 `application/zip` |

### 2.3 认证

所有 `/api/v1/**` 接口必须携带 API Key：

```
X-API-Key: <预共享密钥>
```

- API Key 在 `application.yml` 中通过 `external.api-keys` 列表配置，可配置多组
- 每条 Key 可选绑定 `workflowIds` 白名单（不填表示可调用所有工作流）
- 缺失或不匹配 → HTTP `401 Unauthorized`，响应体：

```json
{ "code": "UNAUTHORIZED", "message": "API Key 缺失或无效" }
```

### 2.4 统一响应包络

非 SSE 接口统一返回：

```json
{
  "code": "OK",
  "message": "",
  "data": { ... }
}
```

错误时：

```json
{
  "code": "<ERROR_CODE>",
  "message": "<可读错误信息>",
  "data": null
}
```

SSE 接口（`/stream`）的认证与业务错误通过事件 payload（见 §5）传递，不使用 HTTP 状态码包络。

### 2.5 错误码

| code | HTTP | 含义 |
|---|---|---|
| `OK` | 200 | 成功 |
| `UNAUTHORIZED` | 401 | API Key 缺失或无权调用该工作流 |
| `VALIDATION_FAILED` | 400 | 入参不合法（变量缺失、文件越界等） |
| `WORKFLOW_NOT_FOUND` | 404 | 工作流 ID 不存在 |
| `RUN_NOT_FOUND` | 404 | 运行 ID 不存在或已过期 |
| `GRAPH_INVALID` | 422 | 工作流图结构未通过校验（无输入/输出节点、不连通、有环） |
| `RUN_ALREADY_STARTED` | 409 | 该运行已启动，不可重复 start |
| `INTERNAL_ERROR` | 500 | 服务器内部错误（堆栈已脱敏） |

### 2.6 标识符

| 名称 | 类型 | 说明 |
|---|---|---|
| `workflowId` | number | 工作流主键（路径参数） |
| `runId` | string | 每次运行的唯一标识，8 位十六进制（如 `a3f9b2c1`），由服务端生成，贯穿工作目录、SSE 事件、下载 URL |

---

## 3. 接口总览

| # | 方法 | 路径 | 用途 |
|---|---|---|---|
| 3.1 | `POST` | `/api/v1/workflows/{workflowId}/runs` | 创建运行（提交输入与文件） |
| 3.2 | `POST` | `/api/v1/workflows/{workflowId}/runs/{runId}/start` | 启动执行（与 3.1 配合的两阶段模式） |
| 3.3 | `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}/stream` | 订阅 SSE 流（思考过程、节点状态、完成事件） |
| 3.4 | `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}` | 查询运行结果（最终变量、状态） |
| 3.5 | `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}/workspace` | 下载工作空间 zip |
| 3.6 | `GET` | `/api/v1/workflows` | 列出当前 API Key 可调用的工作流 |

> **设计说明：** 现有 `/api/workflows/{id}/run` 是「SSE 同步阻塞」模式（一次请求边执行边返回）。对外 API 改为「**提交 → 启动 → 订阅**」三段式，以满足「思考过程通过另一个接口获取」的诉求，并支持客户端重连。

---

## 4. 接口详细规范

### 3.1 创建运行

**请求**

```
POST /api/v1/workflows/{workflowId}/runs
X-API-Key: <key>
Content-Type: application/json  |  multipart/form-data
```

**两种调用形态**

#### 形态 A：仅 JSON 变量（无文件）

```http
POST /api/v1/workflows/12/runs
X-API-Key: demo-key
Content-Type: application/json

{
  "variables": {
    "topic": "大语言模型在企业中的应用",
    "maxTokens": 2048,
    "options": { "lang": "zh", "verbose": false }
  },
  "async": true
}
```

#### 形态 B：JSON 变量 + 文件 / 目录

```http
POST /api/v1/workflows/12/runs
X-API-Key: demo-key
Content-Type: multipart/form-data; boundary=----boundary

------boundary
Content-Disposition: form-data; name="payload"
Content-Type: application/json

{
  "variables": { "topic": "汇总报告" },
  "async": true
}
------boundary
Content-Disposition: form-data; name="files"; filename="report.md"
Content-Type: text/markdown

<文件二进制内容>
------boundary
Content-Disposition: form-data; name="files"; filename="data.csv"
Content-Type: text/csv
X-Relative-Path: inputs/2024/data.csv

<文件二进制内容>
------boundary
Content-Disposition: form-data; name="files"; filename="config.yaml"
Content-Type: application/yaml
X-Relative-Path: config/config.yaml

<文件二进制内容>
------boundary--
```

**关键字段说明**

| 字段 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `variables` | JSON | object | 否 | 自定义输入变量，会合并到工作流上下文（`WorkflowContext.variables`） |
| `async` | JSON | boolean | 否 | `true`（默认）：返回 runId 但不自动启动，需调用 `/start`；`false`：自动启动并直接进入 SSE 流（等价于现有 `/run` 行为） |
| `failStrategy` | JSON | string | 否 | 整体默认失败策略，覆盖节点级配置：`abort`（默认）\| `skip` |
| `ttlHours` | JSON | number | 否 | 工作空间保留时间，默认 `720`（30 天）；超期清理 |
| `files` | multipart | binary[] | 否 | 二进制文件，可重复 |
| `X-Relative-Path` | part header | string | 否 | 该文件在工作目录中的相对路径；缺失时使用 `filename`；含 `/` 时自动创建目录结构（目录上传场景） |

> **目录上传约定：** 客户端在前端使用 `<input webkitdirectory>` 时浏览器原生提供 `webkitRelativePath`，通过该 header 透传到后端即可保留目录结构。后端会做 ZIP slip 防护（`dest.normalize().startsWith(runDir)` 校验）。

**响应（async=true）**

```json
{
  "code": "OK",
  "data": {
    "runId": "a3f9b2c1",
    "workflowId": 12,
    "status": "CREATED",
    "createdAt": "2026-07-01T08:30:00Z",
    "links": {
      "start":  "/api/v1/workflows/12/runs/a3f9b2c1/start",
      "stream": "/api/v1/workflows/12/runs/a3f9b2c1/stream",
      "result": "/api/v1/workflows/12/runs/a3f9b2c1",
      "workspace": "/api/v1/workflows/12/runs/a3f9b2c1/workspace"
    }
  }
}
```

**响应（async=false，立即启动）**

直接以 SSE 流响应（Content-Type 切换为 `text/event-stream`），等价于 §3.3 的输出。

---

### 3.2 启动运行

仅当 `async=true` 创建的运行需要显式启动。

**请求**

```http
POST /api/v1/workflows/12/runs/a3f9b2c1/start
X-API-Key: demo-key
```

无请求体。

**响应**

```json
{
  "code": "OK",
  "data": {
    "runId": "a3f9b2c1",
    "status": "RUNNING",
    "streamUrl": "/api/v1/workflows/12/runs/a3f9b2c1/stream"
  }
}
```

> 启动后客户端应立即打开 `/stream`（§3.3）订阅事件；服务端会缓存最近 N 条事件用于断线重连补发。

---

### 3.3 订阅运行流（SSE）

```
GET /api/v1/workflows/{workflowId}/runs/{runId}/stream
X-API-Key: <key>
Accept: text/event-stream
```

**响应**：`Content-Type: text/event-stream`，按 SSE 协议推送事件，事件名与 payload 详见 §5。

**断线重连**

支持通过 `Last-Event-ID` 头部续传：

```http
GET .../stream
Last-Event-ID: 42
```

服务端为每个事件分配单调递增的 `id:`，客户端重连时携带最后收到的事件 id，服务端补发其后所有事件。事件缓存窗口默认 1000 条或运行结束后保留 1 小时。

**关闭时机**

- 工作流成功完成：推送 `workflow_complete` 后关闭
- 工作流出错：推送 `workflow_error` 后关闭
- SSE 连接 30 分钟超时（与现有 `SseEmitter(1_800_000L)` 一致）
- 客户端主动断开：服务端停止推送但不影响后台执行

---

### 3.4 查询运行结果

```
GET /api/v1/workflows/{workflowId}/runs/{runId}
X-API-Key: <key>
```

**响应**

```json
{
  "code": "OK",
  "data": {
    "runId": "a3f9b2c1",
    "workflowId": 12,
    "status": "SUCCESS",
    "startedAt": "2026-07-01T08:30:05Z",
    "completedAt": "2026-07-01T08:31:12Z",
    "outputs": {
      "summary": "本报告探讨了...",
      "_workingDirFiles": ["report.md", "charts/fig1.png"],
      "_runId": "a3f9b2c1"
    },
    "nodes": [
      {
        "nodeId": "node-1",
        "nodeType": "userInput",
        "label": "用户输入",
        "status": "SUCCESS",
        "startedAt": "2026-07-01T08:30:05Z",
        "completedAt": "2026-07-01T08:30:05Z",
        "output": { "topic": "..." },
        "logsCount": 0,
        "logsUrl": "/api/v1/workflows/12/runs/a3f9b2c1/nodes/node-1/logs"
      },
      {
        "nodeId": "node-2",
        "nodeType": "agent",
        "label": "研究助手",
        "status": "SUCCESS",
        "output": { "research": "..." },
        "logsCount": 124,
        "logsUrl": "/api/v1/workflows/12/runs/a3f9b2c1/nodes/node-2/logs"
      }
    ],
    "workspace": {
      "fileCount": 12,
      "totalBytes": 456789,
      "downloadUrl": "/api/v1/workflows/12/runs/a3f9b2c1/workspace"
    }
  }
}
```

**status 取值**

- `CREATED`：已创建未启动
- `RUNNING`：执行中
- `SUCCESS`：成功完成
- `FAILED`：执行失败（`error` 字段填充）

**节点级思考过程日志查询**

`logsUrl` 指向：

```
GET /api/v1/workflows/{workflowId}/runs/{runId}/nodes/{nodeId}/logs
```

返回该节点执行期间产生的所有日志（`thinking` / `tool_call` / `tool_result` / `info` / `error`），用于回放或归档：

```json
{
  "code": "OK",
  "data": {
    "nodeId": "node-2",
    "logs": [
      { "type": "THINKING", "timestamp": "...", "message": "首先我需要..." },
      { "type": "TOOL_CALL", "timestamp": "...", "message": "search_web", "detail": "{\"q\":\"...\"}" },
      { "type": "TOOL_RESULT", "timestamp": "...", "message": "search_web", "detail": "返回 3 条结果..." }
    ]
  }
}
```

---

### 3.5 下载工作空间

```
GET /api/v1/workflows/{workflowId}/runs/{runId}/workspace
X-API-Key: <key>
```

**响应**

```
HTTP/1.1 200 OK
Content-Type: application/zip
Content-Disposition: attachment; filename*=UTF-8''workflow-12-run-a3f9b2c1.zip

<zip binary>
```

- 应用 `.agentignore` 规则过滤（默认排除 `.hermes/`、`*.log`、`*.tmp`、`.DS_Store`、`Thumbs.db`）
- 404 时返回标准错误包络，`code = RUN_NOT_FOUND`

---

### 3.6 列出可调用工作流

```
GET /api/v1/workflows?page=1&size=20&search=
X-API-Key: <key>
```

**响应**

```json
{
  "code": "OK",
  "data": {
    "total": 1,
    "items": [
      {
        "id": 12,
        "name": "研究报告生成",
        "description": "根据主题生成结构化研究报告",
        "inputs": [
          { "name": "topic", "type": "string", "required": true, "description": "研究主题" },
          { "name": "maxTokens", "type": "number", "required": false }
        ],
        "outputs": [
          { "name": "summary", "type": "string" }
        ]
      }
    ]
  }
}
```

`inputs` 与 `outputs` 由服务端扫描 `graphData` 中 `userInput` 与 `output` 节点的字段定义自动推导，便于第三方系统展示表单。

---

## 5. SSE 事件规范

所有事件格式：

```
event: <事件名>
id: <单调递增整数>
data: <JSON 对象>

```

### 5.1 事件类型

| 事件名 | 触发时机 | 是否终止流 |
|---|---|---|
| `run_started` | 执行引擎启动 | 否 |
| `node_status` | 节点状态变更 | 否 |
| `node_stream` | 节点流式增量（thinking / tool_call / tool_result） | 否 |
| `workflow_complete` | 工作流成功完成 | 是 |
| `workflow_error` | 工作流出错 | 是 |

### 5.2 `run_started`

```json
{
  "runId": "a3f9b2c1",
  "workflowId": 12,
  "timestamp": "2026-07-01T08:30:05Z"
}
```

### 5.3 `node_status`

```json
{
  "runId": "a3f9b2c1",
  "type": "node_status",
  "nodeId": "node-2",
  "nodeType": "agent",
  "label": "研究助手",
  "status": "RUNNING",
  "timestamp": "2026-07-01T08:30:06Z"
}
```

状态变为 `SUCCESS` 时额外携带：

```json
{
  "...": "...",
  "status": "SUCCESS",
  "output": { "research": "..." },
  "completedAt": "2026-07-01T08:30:45Z"
}
```

状态变为 `FAILED` 时携带 `error` 字段。

### 5.4 `node_stream`（思考过程）

```json
{
  "runId": "a3f9b2c1",
  "type": "node_stream",
  "nodeId": "node-2",
  "kind": "thinking",
  "content": "首先我需要搜索相关资料...",
  "toolName": null,
  "timestamp": "2026-07-01T08:30:07Z"
}
```

| `kind` | 含义 | `toolName` |
|---|---|---|
| `thinking` | LLM / Agent 的思考片段 | null |
| `tool_call` | 工具调用开始（参数摘要） | 工具名 |
| `tool_result` | 工具调用返回（结果摘要） | 工具名 |

### 5.5 `workflow_complete`

```json
{
  "runId": "a3f9b2c1",
  "type": "workflow_complete",
  "success": true,
  "output": {
    "summary": "...",
    "_workingDirFiles": ["report.md", "charts/fig1.png"],
    "_runId": "a3f9b2c1"
  },
  "completedAt": "2026-07-01T08:31:12Z",
  "workspaceUrl": "/api/v1/workflows/12/runs/a3f9b2c1/workspace",
  "resultUrl": "/api/v1/workflows/12/runs/a3f9b2c1"
}
```

### 5.6 `workflow_error`

```json
{
  "runId": "a3f9b2c1",
  "type": "workflow_error",
  "success": false,
  "error": "节点 node-3 执行失败：LLM 调用超时",
  "failedNodeId": "node-3",
  "completedAt": "2026-07-01T08:31:00Z"
}
```

---

## 6. 调用时序示例

### 6.1 完整异步流程

```
客户端                                              服务端
  │                                                   │
  │── POST /runs (variables + files) ─────────────────▶│  创建运行 + 工作目录
  │◀────────── 200 { runId, links } ──────────────────│
  │                                                   │
  │── POST /runs/{runId}/start ───────────────────────▶│  启动执行
  │◀────────── 200 { status: RUNNING } ───────────────│
  │                                                   │
  │── GET /runs/{runId}/stream ───────────────────────▶│  订阅 SSE
  │◀── event: run_started ────────────────────────────│
  │◀── event: node_status (node-1 RUNNING) ───────────│
  │◀── event: node_status (node-1 SUCCESS) ───────────│
  │◀── event: node_status (node-2 RUNNING) ───────────│
  │◀── event: node_stream (node-2 thinking) ──────────│
  │◀── event: node_stream (node-2 thinking) ──────────│
  │◀── event: node_stream (node-2 tool_call) ─────────│
  │◀── event: node_stream (node-2 tool_result) ───────│
  │◀── event: node_status (node-2 SUCCESS) ───────────│
  │◀── event: node_status (node-3 SUCCESS) ───────────│
  │◀── event: workflow_complete ──────────────────────│  流结束
  │                                                   │
  │── GET /runs/{runId} ──────────────────────────────▶│  查询结果
  │◀────────── 200 { outputs, nodes[] } ──────────────│
  │                                                   │
  │── GET /runs/{runId}/workspace ────────────────────▶│  下载 zip
  │◀────────── 200 application/zip ───────────────────│
```

### 6.2 断线重连

```
客户端在事件 id=42 处断开，重连：
  │── GET /runs/{runId}/stream ───────────────────────▶│
  │   Last-Event-ID: 42                                 │
  │◀── event: node_status (重发 id=43..) ──────────────│
```

### 6.3 同步阻塞模式（async=false）

适合简单集成，一次请求直接返回 SSE 流：

```
  │── POST /runs (async=false) ───────────────────────▶│
  │◀── event: run_started ────────────────────────────│
  │◀── event: node_stream ... ────────────────────────│
  │◀── event: workflow_complete ──────────────────────│
```

---

## 7. 限制与约束

| 项目 | 限制 | 说明 |
|---|---|---|
| 单次执行超时 | 5 分钟（软） | 引擎层 `MAX_EXECUTION_SECONDS=300` |
| SSE 连接超时 | 30 分钟 | 与现有 `SseEmitter` 一致 |
| 单文件大小 | 100 MB | 超过建议改用对象存储预签名 URL |
| 单次上传文件数 | 50 | multipart part 数上限 |
| 总请求体大小 | 500 MB | Spring `spring.servlet.multipart.max-request-size` |
| 工作目录保留 | 默认 30 天 | 通过创建运行时 `ttlHours` 调整 |
| 并发运行 | 受 CPU 核数约束 | 引擎线程池 `fixedThreadPool(processors)` |
| 工作流图校验 | 必须有 userInput + output 节点且通过中间节点连通 | 详见 §8 |

---

## 8. 工作流图结构校验

服务端在 `/runs` 创建时或 `/start` 启动时会校验 `graphData`：

1. 至少 1 个 `userInput` 类型节点
2. 至少 1 个 `output` 类型节点
3. 从任一 `userInput` 到任一 `output` 必须存在有向路径，且路径上至少包含 1 个中间节点（直接 `input → output` 边不满足）
4. DAG 无环

不满足时返回 `GRAPH_INVALID`（HTTP 422），错误信息指明具体原因。

---

## 9. curl 调用示例

### 9.1 纯 JSON 输入

```bash
curl -X POST https://host/api/v1/workflows/12/runs \
  -H "X-API-Key: demo-key" \
  -H "Content-Type: application/json" \
  -d '{
    "variables": { "topic": "AI 在医疗领域的应用" },
    "async": true
  }'
```

### 9.2 JSON + 文件 + 目录

```bash
curl -X POST https://host/api/v1/workflows/12/runs \
  -H "X-API-Key: demo-key" \
  -F 'payload={"variables":{"topic":"汇总"},"async":true};type=application/json' \
  -F 'files=@report.md' \
  -F 'files=@data.csv;type=text/csv' \
  -H 'X-Relative-Path: inputs/2024/data.csv' \
  -F 'files=@config/config.yaml;type=application/yaml'
```

> 注：curl 的 `X-Relative-Path` 是自定义 part header，后端在解析 multipart 时需读取该头部；如果客户端无法发送自定义 part header，可改为在 `payload.inputs.uploadedFiles` 中显式提供路径列表，并使用默认的 `filename`。

### 9.3 启动 + 订阅

```bash
# 启动
curl -X POST https://host/api/v1/workflows/12/runs/a3f9b2c1/start \
  -H "X-API-Key: demo-key"

# 订阅流
curl -N -H "X-API-Key: demo-key" \
  https://host/api/v1/workflows/12/runs/a3f9b2c1/stream
```

### 9.4 查询结果 + 下载

```bash
curl -H "X-API-Key: demo-key" \
  https://host/api/v1/workflows/12/runs/a3f9b2c1

curl -H "X-API-Key: demo-key" \
  https://host/api/v1/workflows/12/runs/a3f9b2c1/workspace \
  -o workspace.zip
```

---

## 10. 与现有内部接口的映射关系

| 外部接口 | 内部接口 | 备注 |
|---|---|---|
| `POST /api/v1/.../runs` | （新增） | 复用 `WorkflowRunDirManager.createRunDir` + 文件写入逻辑（现有 `WorkflowController:165-192`） |
| `POST /api/v1/.../runs/{runId}/start` | `WorkflowEngine.executeAsync` | 异步触发 |
| `GET /api/v1/.../runs/{runId}/stream` | `SseEmitter` + `WorkflowRunEvent` | 复用现有事件工厂 |
| `GET /api/v1/.../runs/{runId}` | `RunHistoryController:62` | 扩展为对外字段 |
| `GET /api/v1/.../runs/{runId}/workspace` | `WorkflowController:205` | 复用 zip 打包 + `.agentignore` 过滤 |
| 节点日志 | （新增） | 从 `WorkflowRunNode.logs` 反序列化 |

实现建议：新增 `ExternalApiController`（路径 `/api/v1/**`）与独立 `ExternalApiAuthFilter`（解析 `X-API-Key`），不污染现有 Controller，便于后续按版本演进。
