同步协议

本页是给所有为 OHarness 实现同步服务的人准备的互操作约定。它描述客户端发送什么,以及客户端依赖哪些行为;它不要求使用 @oharness/sync 所用的 JSONL 存储。

同一个源下有两个相互独立的协议:

  • 会话同步通过只追加的 feed 镜像会话记录、产物修订版本和会话元数据。
  • 记忆同步通过清单和比较并交换(compare-and-swap)来协调 Markdown 笔记。它是可选的。服务器可以只实现会话同步而不实现记忆。

Base URL 不包含路径、查询参数、凭据或片段。生产环境的客户端只接受 HTTPS;用于开发的 localhost、127.0.0.1 和 [::1] 接受普通 HTTP。下面的示例使用 https://sync.example.com。

本页内容

  • [兼容目标](#compatibility-target)
  • [认证与租户](#authentication-and-tenancy)
  • [会话同步数据模型](#session-sync-data-model)
  • [会话端点](#session-endpoints)
  • [会话错误响应](#session-error-responses)
  • [记忆同步](#memory-sync)
  • [配置 OHarness 客户端](#configuring-an-oharness-client)
  • [实现检查清单](#implementation-checklist)
  • [非必需项](#non-requirements)

兼容目标

本文档描述协议版本 1,即 OHarness 0.5.x 所使用的版本。目前没有能力查询端点,也没有内容协商。保持 /v1/ 向后兼容:可以添加可选字段,但不要改变已有字段的含义,也不要把原本可选的字段变成必需。

对于会话同步,未知的请求字段应当被忽略。记忆更严格,会以 400 {"code":"unknown_field"} 拒绝它们;新客户端借助这个响应识别出较旧的记忆服务器,并在不使用 have 优化的情况下重试项目快照。

所有时间戳都是 Unix 毫秒。所有正文都是 JSON。成功响应使用 Content-Type: application/json;客户端不依赖任何特定的 JSON 空白或属性顺序。

认证与租户

除 GET /v1/health 外,每个路由都要求:

Authorization: Bearer <token>

一个有效的 token 恰好解析为一个稳定的主体 id。服务器必须从经过验证的 token 推导出所有者,绝不能取自请求中提供的用户 id。会话 token 还可以另外带有团队 id。

不带 team 时,会话请求访问的是该主体的个人 feed。带有 team 时,只有在检查过成员关系之后,才访问该团队的 feed。未知或未授权的团队返回 403;绝不能悄悄退回到个人 feed。

认证在 Bearer 传输之外有意不做标准化。内置服务器接受本地签发的不透明 token、HS256 JWT,以及通过 JWKS 验证的 JWT。其他实现可以使用数据库或不同的 token 验证方式,只要对外可观察的主体和团队语义保持一致。

记忆有更严格的身份检查,见下文。特别是,由 oharness-sync token 签发的不透明 token 足以用于会话同步,但当前的 OHarness 记忆客户端使用的是它的 Cloud 账号 token。

会话同步数据模型

记录

记录是会话中一个不可变的条目。目前它通常包含一条会话记录或一个产物修订的封装,但服务器必须把 body 当作不透明的 JSON。

{
  "session": "20260914_abcd",
  "id": "stable-record-id",
  "seq": 12,
  "device": "device-id",
  "at": 1789344000000,
  "body": { "opaque": "to the server" }
}
字段类型含义
sessionstring包含该记录的会话
idstring用于重试去重的稳定 id
seqnumber会话内的 Lamport 序号
devicestring创建该记录的设备
atnumber创建时间,Unix 毫秒
body任意 JSON原样存储并原样返回的载荷

去重键是 (session, id)。重复推送同一个键绝不能追加第二个事件,即使之前的响应丢失了。不要解析、规范化或改写 body。

会话元数据

{
  "id": "20260914_abcd",
  "gen": 1,
  "updatedAt": 1789344000000,
  "body": { "opaque": "session metadata" }
}

body 是不透明的。deleted: true 表示墓碑(tombstone),通常没有 body。元数据按以下元组以最后写入者为准,优先级从高到低:

  1. gen 更大的胜出。一次压缩或改写会让代数(generation)递增。
  2. 同一代数内,updatedAt 更大的胜出。
  3. 元组相同说明已经持有,不会产生事件。

胜出的更新(包括墓碑)会被追加到 feed,让已经读过旧值的设备能看到这个变化。

游标

游标是由服务器发放的非负整数。它标识某个 (owner, project) feed 中的一个位置。客户端把它当作不透明的值:存下来,作为 since 发回去,只期望它永远不会倒退。

内置的 JSONL 实现使用递增的事件编号。数据库实现可以使用序列。游标不要求是字节偏移、时间戳或记录的 seq。

游标属于某一个 feed,绝不能在不同用户、团队或项目之间复用。feed 存储是持久状态。如果服务器丢失了一个 feed 而客户端仍保留着它的游标,客户端可能会永久地领先于新的 feed;协议 v1 没有游标重置握手。

会话端点

GET /v1/health

无需认证的存活检查端点。

HTTP/1.1 200 OK
Content-Type: application/json

{"ok":true}

这只证明进程和路由可达。它不证明 token 可用、持久存储已挂载,或记忆已配置。

GET /v1/projects

列出所选所有者名下拥有 feed 的项目。

GET /v1/projects
GET /v1/projects?team=platform

响应:

{"projects":["project-a","project-b"]}

所有者没有任何 feed 时返回空数组。当前 OHarness 客户端生成的 ID 匹配 [A-Za-z0-9_-]{1,128},应原样返回。服务器可以拒绝或可逆地编码其他输入;绝不要把不受信任的 id 拼接进文件系统路径。

当前的按项目镜像在常规的推送/拉取循环中不会调用这个路由,但它是公开客户端的一部分,完整实现和跨设备项目发现都需要它。

POST /v1/push

原子地追加新记录和胜出的会话元数据。

{
  "project": "project-a",
  "team": "platform",
  "records": [
    {
      "session": "20260914_abcd",
      "id": "record-12",
      "seq": 12,
      "device": "laptop",
      "at": 1789344000000,
      "body": { "role": "user", "content": "hello" }
    }
  ],
  "sessions": [
    {
      "id": "20260914_abcd",
      "gen": 0,
      "updatedAt": 1789344000000,
      "body": { "id": "20260914_abcd", "title": "Example" }
    }
  ]
}

team、records 和 sessions 是可选的。缺失的数组视为空数组。至少需要 project。较早的类型声明里有一个顶层的 device;OHarness 客户端不发送它,兼容的服务器也绝不能要求它。设备身份放在每条记录上。

响应:

{"cursor":42,"accepted":2}

cursor 是本次推送后 feed 的当前末尾。accepted 统计新的记录事件加上胜出的会话元数据事件。重复的记录和过期/相同的会话元数据不计入。

整个请求都可以重试。在提交任何条目之前先完成校验。不要接受部分批次:只要有任何条目格式错误或超出限制,就对整个请求返回错误。

当前客户端和内置服务器使用的限制:

限制值
单次推送的记录数500
单次推送的会话元数据条目数500
序列化后的请求正文8 MiB

两个数组的限制相互独立:一个请求可以同时包含 500 条记录和 500 条会话条目。

GET /v1/pull

读取某个游标之后的 feed 事件。

GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
查询参数是否必需含义
project是不透明的项目 id
since常规使用中是返回严格位于该游标之后的事件;0 表示从头开始
limit否每页大小,限制在 1..500;默认 500
wait否长轮询秒数,限制在 0..30;默认 0
team否使用团队 feed 而不是主体自己的 feed

响应:

{
  "cursor": 22,
  "records": [],
  "sessions": [],
  "more": false
}

按 feed 顺序返回事件,且不包含仅限服务端的字段。游标是返回的最后一个事件。如果没有新事件,它保持等于 since。只有在下一页已经可用时才设置 more: true;客户端随后会立即再次拉取。

当 wait > 0 且 feed 已经追平时,挂起请求,直到有事件被追加或等待超时。超时是一个成功的空响应,而不是 HTTP 错误。断开长轮询绝不能改变 feed 状态。

会话错误响应

会话错误是带有简短、人类可读的 error 字符串的 JSON 对象:

{"error":"missing or bad token"}

使用常规的状态码:

状态码何时使用
400JSON 无效、缺少 project、游标无效或条目格式错误
401Bearer token 缺失、过期或无效;需包含 WWW-Authenticate: Bearer
403已认证的主体不是所请求团队的成员
413请求字节数或条目数超出限制
404未知路由
500意外的存储失败;不要声称部分推送成功

镜像会对网络错误和普通的服务器故障进行退避重试。401 会让该镜像停止,直到用可用的凭据重新启动。

记忆同步

记忆同步是可选的,并且与只追加的会话 feed 相互独立。三个路由都是需要认证的 POST 请求,带 Content-Type: application/json:

  • /v1/memory/identity
  • /v1/memory/snapshot
  • /v1/memory/commit

当前客户端仅限原生应用。带有 Origin 请求头的请求会被以 403 {"code":"native_client_required"} 拒绝。

记忆身份

每个请求都带有 expectedPrincipal,取自本地解码的 Cloud 账号,作为一种同意锚定。服务器必须独立验证 Bearer token,并要求验证出的主体与之一致。绝不能仅凭 expectedPrincipal 就授权某个对象前缀。

请求:

{"expectedPrincipal":"user-id"}

响应:

{
  "principal": "user-id",
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

内置实现通过 Supabase 的 GET /auth/v1/user 验证 token,但兼容的实现可以使用其他账号服务。它必须拒绝匿名、已删除、已过期或其他无效的账号,并返回客户端所期望的同一个稳定主体 id。

作用域与笔记格式

项目作用域用 project 表示,不带 scope 属性:

{"project":"project-a"}

用户作用域带有 scope: "user",且不能包含 project:

{"scope":"user"}

协议要求两种作用域都支持。当前内置的 R2 适配器在用户前缀上有一个已知的键允许列表缺口;这不会改变独立实现所遵循的传输约定。

一条笔记是:

{
  "id": "borderless-cards-a91",
  "summary": "Pricing cards use fills, not borders",
  "body": "Use whitespace and background fills.",
  "tags": ["design"],
  "type": "feedback",
  "createdAt": 1789344000000,
  "updatedAt": 1789345000000
}

type 是可选的,出现时取值为 user、feedback、project 或 reference 之一。updatedAt 是可选的元数据,有意不计入内容哈希。

校验限制是传输约定的一部分:

字段约束
主体、项目和笔记 id[A-Za-z0-9_-]{1,128};同时拒绝 __proto__、constructor 和 prototype
summary去除首尾空白,单行,1–500 个字符
body去除首尾空白的字符串,UTF-8 下不超过公布的 maxNoteBytes
tags最多 30 个;每个 1–64 个字符,不含空白、逗号或换行
createdAt非负的安全整数
updatedAt可选的非负安全整数

现存笔记的 id 在不区分大小写的情况下必须唯一。这是必需的,因为服务器绝不能制造出常见的不区分大小写的客户端文件系统无法表示的状态。

笔记哈希是小写十六进制的 SHA-256,基于 UTF-8 JSON,使用下面这个确切的按位置排列的数组计算:

JSON.stringify([id, summary, body, tags, createdAt])

当且仅当存在 type 时,把它追加上去:

JSON.stringify([id, summary, body, tags, createdAt, type])

实现必须对这些值复现 JavaScript JSON.stringify 的结果。这个兼容性例外保留了在笔记类型出现之前生成的哈希。

POST /v1/memory/snapshot

项目请求:

{
  "expectedPrincipal": "user-id",
  "project": "project-a",
  "knownEtag": "optional-previous-etag",
  "have": {
    "borderless-cards-a91": "64-lowercase-hex-characters"
  }
}

对于用户作用域,把 project 换成 "scope":"user"。

knownEtag 用于未变化时的快速路径。have 把笔记 id 映射到客户端已经持有的哈希;服务器可以省略这些笔记正文,而无需从对象存储中读取它们。拒绝键数超过 maxNotes * 2 + 1000 的 have 映射。

未变化时的响应:

{
  "principal": "user-id",
  "etag": "same-etag",
  "unchanged": true,
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

完整或增量响应:

{
  "principal": "user-id",
  "etag": "current-etag-or-null",
  "entries": [
    {
      "id": "borderless-cards-a91",
      "hash": "64-lowercase-hex-characters",
      "note": { "id": "borderless-cards-a91", "summary": "...", "body": "...", "tags": [], "createdAt": 1789344000000 }
    },
    { "id": "already-held", "hash": "64-lowercase-hex-characters" },
    { "id": "deleted-note", "hash": null }
  ],
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

只有当 have[id] 等于其哈希时,现存条目才可以省略 note。墓碑带有 hash: null,没有 note。没有清单时 etag 为 null。

POST /v1/memory/commit

{
  "expectedPrincipal": "user-id",
  "project": "project-a",
  "etag": "etag-from-snapshot-or-null",
  "changes": [
    {
      "id": "borderless-cards-a91",
      "hash": "64-lowercase-hex-characters",
      "note": { "id": "borderless-cards-a91", "summary": "...", "body": "...", "tags": [], "createdAt": 1789344000000 }
    },
    { "id": "deleted-note", "hash": null }
  ],
  "have": {}
}

对于用户作用域,把 project 换成 "scope":"user"。创建/更新必须带有一条笔记,其 id 和计算出的哈希都与该变更一致。删除带有 hash: null,没有 note。changes 必须包含 1 到公布的 maxChanges 个条目,且 id 不能重复。

只有当 etag 仍然标识当前清单时,才应用这次提交。遇到过期的 ETag 时,返回 409 {"code":"revision_conflict"},并保持已发布的清单不变。先写入新的不可变笔记修订版本,再发布新的清单,且清单的发布必须是原子的。并发写入中落败的一方可能留下无法访问的不可变对象,留待之后的垃圾回收,但任何部分状态都不能通过清单变得可见。响应的结构与成功的快照相同,带有新的 ETag 和当前条目。

不要立即丢弃删除。在公布的期限内保留墓碑,让离线的客户端能够看到它们。调低配额不能把已经超出的主体困住:拒绝超过 maxNotes 的增长,但继续允许删除。

记忆错误与限制

记忆错误使用稳定的、机器可读的 code:

{"code":"revision_conflict"}

当前实现使用的代码包括:

状态码代码
400invalid_body、unknown_field、invalid_project、invalid_scope、invalid_have、invalid_changes、invalid_note、hash_mismatch、id_case_conflict
401sign_in_required
403native_client_required、account_mismatch
409revision_conflict
413memory_quota_exceeded
415json_required
429rate_limited
502auth_unavailable、invalid_manifest、missing_memory_object、memory_integrity_error、memory_sync_failed
503auth_not_configured、r2_not_configured

内置默认值为:每个作用域 5000 条现存笔记,每次提交 500 个变更,每条笔记正文 128 KiB,每个请求 4 MiB,每个响应 16 MiB 的序列化条目数据,墓碑保留 90 天。只有 quota 中列出的四项客户端可以据此行动的值会在传输中公布。客户端也会应用自己的硬性安全上限,所以调高服务器配额并不保证客户端会接受任意大的响应。

配置 OHarness 客户端

把目标地址写在用户配置里,绝不要写在仓库的配置里:

{
  "sync": {
    "url": "https://sync.example.com",
    "token": "opaque-session-token",
    "team": "optional-team",
    "enabled": true
  }
}

设置了 OHARNESS_HOME 时,文件是 $OHARNESS_HOME/config.json;在 Linux 上设置了 XDG 时,是 $XDG_CONFIG_HOME/oharness/config.json;其他情况下是 ~/.oharness/config.json。更换目标地址后需要重启 OHarness。

当前的优先级是:

sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token

因此,只有当用户配置中没有 sync.url 时,OHARNESS_SYNC_URL 才会替换硬编码的默认值。无论目标地址或凭据是什么,OHARNESS_SYNC=0 都会禁用会话同步。

仓库中的 oharness.config.json 和 .oharness.json 文件不能设置 sync.url、sync.token 或 sync.team;OHarness 会丢弃这些值。仓库只能关闭同步或添加排除项。这可以防止一个克隆下来的仓库把用户的会话记录重定向到别处。

记忆使用同一个解析出的 Base URL,但当前的记忆客户端使用的是 Cloud 账号凭据,而不是 sync.token 或 OHARNESS_SYNC_TOKEN。因此,要自托管记忆,客户端登录和记忆服务器的身份验证器必须对应到同一个主体。

实现检查清单

兼容的服务应当通过以下所有行为:

  • 健康检查无需 token 即可工作;所有数据路由在没有 token 时都失败即拒绝。
  • 使用相同项目 id 的两个主体看不到彼此的数据。
  • 个人 feed 和团队 feed 相互独立;未授权的团队返回 403。
  • 重试推送不会产生重复的记录或过期的会话元数据。
  • 拉取有序、分页、游标单调递增,并且可以安全地长轮询。
  • 会话正文在推送/拉取后,在 JSON 值层面保持不变。
  • 会话删除和记忆删除都能到达离线的第二台设备。
  • 过期的记忆 ETag 不会对清单发布任何变更。
  • 记忆对象键始终使用服务器验证过的主体。
  • 哈希不匹配、仅大小写不同的笔记 id 冲突以及超大请求,都会在发布清单之前被拒绝。
  • 持久的 feed 和清单状态能在进程重启和重新部署后保留。

仓库中 test/sync.test.ts 的会话测试和 script/sync-memory.test.ts 的记忆测试,是这份约定的可执行示例。它们目前直接实例化内置服务器,而不是接受一个远程 URL,所以其他实现在声称兼容之前,应当移植相同的用例,或提供一个小型适配层。

做第一次远程冒烟测试时,签发一个一次性 token,并把它的值替换到下面。不要在 shell 历史中使用生产账号的 token:

export SYNC_URL=https://sync.example.com
export SYNC_TOKEN=test-token

curl "$SYNC_URL/v1/health"

curl -X POST "$SYNC_URL/v1/push" \
  -H "Authorization: Bearer $SYNC_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"project":"conformance","records":[{"session":"s1","id":"r1","seq":1,"device":"test","at":1,"body":{"value":"unchanged"}}],"sessions":[]}'

curl "$SYNC_URL/v1/pull?project=conformance&since=0&limit=500" \
  -H "Authorization: Bearer $SYNC_TOKEN"

把同样的正文再推送一次,确认 accepted 为 0;然后从返回的游标处拉取,确认数组为空且游标没有变化。这些检查只能确认可达性和基本的幂等性,不能确认租户隔离或并发 CAS 的安全性。

非必需项

兼容的服务器不需要:

  • 运行模型、执行工具或理解会话记录的消息格式;
  • 使用 JSONL、R2、Supabase 或任何特定的数据库;
  • 提供注册、登录、token 签发或浏览器应用;
  • 为了实现会话同步而实现记忆;
  • 开放 CORS。当前的客户端都是原生的 Node/Electron 客户端。

把这些关注点排除在协议之外,正是一个小型自托管服务和一个多租户部署能使用同一种传输格式的原因。