# 插件

给空间加上你自己的动作：用任何语言写的 HTTPS 服务，或者通过 runner 在你自己机器上跑的命令。带签名的任务、按范围读取输入、结果写成附加信息和新文件。

插件给 Side Space 加动作：质检、自家的转写服务、在自己显卡上跑的超分辨率……插件的代码从不在 Side Space 服务器上运行。它要么是接收签名任务的 **HTTPS 服务**，要么是 **runner**：在你自己机器上主动领任务的命令，不需要公网地址。

空间管理员安装后，它的动作以 `x-acme.qc.check` 这样的名字出现在内置动作旁边：可以手动运行、放进[流水线](/docs/actions#流水线)、被 agent 通过 MCP 调用，也一样[按内容缓存](/docs/actions#按内容缓存)（只在本空间内复用，插件升版本后重新处理）。

## 清单

```json
{
  "name": "x-acme.qc",
  "title": "Acme QC",
  "version": "1.2.0",
  "endpoint": "https://qc.acme.example/side/jobs",
  "timeout_seconds": 1800,
  "actions": [
    {
      "name": "check",
      "title": "检查掉帧",
      "accepts": ["video/"],
      "writes": ["x-acme.qc"],
      "params_schema": { "type": "object", "properties": { "strict": { "type": "boolean" } } }
    },
    { "name": "report", "title": "生成质检报告", "accepts": ["video/"], "writes": ["x-acme.qc", "files"] }
  ]
}
```

- `name` 以 `x-` 开头；动作名是 `<name>.<action>`。
- `accepts` 是 MIME 前缀列表（空表示任何文件）。发任务之前，参数会按 `params_schema` 校验。
- `writes` 是动作可能写的所有东西：`x-…` 命名空间，声明了的话还有 `transcript`、`ai.tags`、`ai.generation`、`user` 和 `files`（新文件，会带 `derived` 附加信息）。结果里写了没声明的内容，整次运行失败，什么都不写。
- `version` 是缓存键的一部分：升版本后会重新处理文件。

```sh
sidespace plugin install "Film A" https://qc.acme.example/side/manifest.json   # 打印签名密钥，只显示这一次
sidespace plugin list "Film A"
sidespace run "Film A" /Ep01/a.mov x-acme.qc.check --param strict=true
sidespace plugin enable "Film A" plg_… --off
sidespace plugin rm "Film A" plg_…
```

> [!WARNING]
> 安装插件等于把空间里文件的内容交给它，所以只有空间管理员能装，令牌需要 `plugins:manage`。安装前请看清它声明会写什么。

## HTTPS 插件

服务器向 `endpoint` POST 一个**任务**，签名方式和 [Webhook](/docs/changes#验证签名) 相同（`Side-Signature`，用安装时给的密钥）：

```json
{
  "type": "run",
  "run_id": "run_…",
  "action": "check",
  "attempt": 1,
  "space_id": "spc_…",
  "node_id": "nod_…",
  "version_id": "ver_…",
  "path": "/Ep01/a.mov",
  "name": "a.mov",
  "mime": "video/quicktime",
  "size": 734003200,
  "params": { "strict": true },
  "sidecars": { "media": { "duration_s": 92.4, "width": 3840, "height": 2160, "fps": 24 } },
  "input": { "url": "https://blocks.side.example.com/files/ver_…?t=…", "expires_at": "…" },
  "callback": { "url": "https://side.example.com/v1/plugin-runs/run_…", "token": "srt_…", "expires_at": "…" }
}
```

`input.url` 可以按字节范围读取这个版本的内容，有效期覆盖整个超时时间，所以只读需要的那一段就行。60 秒内回答：

| 回答 | 含义 |
|---|---|
| `200` 加结果 `{"sidecars": {"x-acme.qc": {…}}, "files": […]}` | 完成 |
| `202` | 在后台处理，之后通过回调完成 |
| `4xx` | 运行失败，显示你给的原因 |
| `5xx` 或超时 | 和普通动作一样重试 |

回答 `202` 之后，带 `Authorization: Bearer <callback.token>` 调用回调：

- `POST {callback.url}/progress`，带 `{"progress": 0.4}`
- `POST {callback.url}/upload` 拿上传票据，再把文件 POST 到网关的 `/stream-upload`，得到 manifest
- `POST {callback.url}/complete` 交结果，或者 `{"error": "…", "retry": true}`

超过 `timeout_seconds` 还没完成，会重新发一次，最多 3 次。

```ts tab=TypeScript
import { PluginRun } from "@sidespace/sdk";
import { verifyWebhookSignature } from "@sidespace/sdk/webhooks";

// 在你的 HTTP 处理函数里，用原始 body：
if (!verifyWebhookSignature(secret, req.headers["side-signature"], rawBody)) return res.writeHead(401).end();
res.writeHead(202).end();

const run = new PluginRun(JSON.parse(rawBody));
await run.progress(0.5);
const manifest = await run.upload(reportBytes);
await run.complete({ sidecars: { "x-acme.qc": { passed: true } }, files: [{ path: "/Ep01/a.qc.pdf", manifest }] });
```

仓库里的 `tests/e2e/fakeplugin.py` 是一个只用 Python 标准库的完整参考实现，包括同步回答、回答 202 后回调、上传文件和验签。

## Runner：在自己的机器上跑

不想部署 HTTPS 服务，或者要用本机的模型和显卡（whisper.cpp、本地视觉模型、调色工具）时，把清单里的 `endpoint` 换成 `"runtime": "runner"`，然后在那台机器上运行：

```sh
sidespace plugin install "Film A" ./local-tools.json         # 记下 plg_… 和 whsec_…
sidespace runner --plugin plg_… --secret whsec_… --jobs 2 -- ./qc.sh
```

- runner 向服务器长轮询领任务，所以那台机器不需要公网地址，在 NAT 后面、笔记本上都行。它不在线时任务会排队。
- 每个任务调用一次你的命令。输入文件已经下载好，路径在 `SIDE_INPUT`；任务 JSON 在 `SIDE_JOB`（stdin 上也有一份）；`SIDE_ACTION` 和 `SIDE_PARAMS` 是动作名和参数；新文件写到 `SIDE_OUTPUT` 目录。
- 在 stdout 打印结果 JSON，runner 会上传其中列出的文件，再完成这次运行：

```json
{ "sidecars": { "x-acme.qc": { "passed": true } }, "files": [{ "path": "/Ep01/qc.pdf", "file": "qc.pdf", "mime": "application/pdf" }] }
```

- stderr 里的 `progress 0.4`（或 `progress: 40%`）会显示为进度，其余输出原样打印。退出码 `75` 表示稍后重试，其他非零退出码让运行失败，原因取 stderr 的最后几行。
- `timeout_seconds` 内没有 runner 来领，任务会重新排队，3 次后失败并提示“没有 runner”。`plugin list` 会显示 runner 最后一次来领任务的时间。

其他语言自己实现 runner：带 `Authorization: Bearer plg_…:whsec_…` 调用 `POST /v1/plugin-runs/claim?wait=25`，拿到的任务和 HTTPS 插件收到的一样，再用任务里的回调汇报。
