Protocolo de sincronización
Esta página es el contrato de interoperabilidad para quien implemente un servicio de sincronización para OHarness. Describe lo que envía el cliente y el comportamiento del que depende; no exige el almacenamiento JSONL que usa @oharness/sync.
Hay dos protocolos independientes bajo un mismo origen:
- La sincronización de sesiones replica transcripciones, revisiones de artefactos y metadatos de sesión mediante un feed de solo anexado.
- La sincronización de la memoria concilia notas Markdown mediante un manifiesto y compare-and-swap. Es opcional. Un servidor puede implementar la sincronización de sesiones sin implementar la memoria.
La URL base no contiene ruta, query, credenciales ni fragmento. Los clientes de producción solo aceptan HTTPS; HTTP sin cifrar se acepta para desarrollo en localhost, 127.0.0.1 y [::1]. Los ejemplos de abajo usan https://sync.example.com.
En esta página
- [Objetivo de compatibilidad](#compatibility-target)
- [Autenticación y multitenencia](#authentication-and-tenancy)
- [Modelo de datos de la sincronización de sesiones](#session-sync-data-model)
- [Endpoints de sesión](#session-endpoints)
- [Respuestas de error de sesión](#session-error-responses)
- [Sincronización de la memoria](#memory-sync)
- [Configurar un cliente de OHarness](#configuring-an-oharness-client)
- [Lista de comprobación de implementación](#implementation-checklist)
- [Lo que no se exige](#non-requirements)
Objetivo de compatibilidad
Este documento describe la versión 1 del protocolo, la que usa OHarness 0.5.x. Actualmente no hay endpoint de capacidades ni negociación de contenido. Mantén /v1/ compatible hacia atrás: añade campos opcionales, no cambies el significado de un campo existente y no conviertas en obligatorio un campo que antes era opcional.
En la sincronización de sesiones, los campos de solicitud desconocidos deben ignorarse. La memoria es más estricta y los rechaza con 400 {"code":"unknown_field"}; los clientes nuevos usan esta respuesta para detectar un servidor de memoria antiguo y reintentar una instantánea de proyecto sin la optimización have.
Todas las marcas de tiempo son milisegundos Unix. Todos los cuerpos son JSON. Las respuestas correctas usan Content-Type: application/json; los clientes no dependen de ningún espaciado JSON ni orden de propiedades concreto.
Autenticación y multitenencia
Todas las rutas salvo GET /v1/health requieren:
Authorization: Bearer <token>
Un token válido se resuelve exactamente a un id de principal estable. El servidor debe obtener el propietario del token verificado, nunca de un id de usuario aportado en una solicitud. Un token de sesión puede contener además ids de equipo.
Sin team, las solicitudes de sesión se dirigen al feed personal del principal. Con un team, se dirigen al feed de ese equipo solo después de comprobar la pertenencia. Un equipo desconocido o no autorizado devuelve 403; no debe recurrir en silencio al feed personal.
La autenticación no se estandariza deliberadamente más allá del transporte Bearer. El servidor incluido acepta tokens opacos emitidos localmente, JWT HS256 y JWT verificados mediante JWKS. Otra implementación puede usar una base de datos u otro verificador de tokens siempre que la semántica observable de principal y equipo sea la misma.
La memoria tiene una comprobación de identidad más estricta, descrita más abajo. En concreto, un token opaco emitido por oharness-sync token basta para la sincronización de sesiones, pero el cliente de memoria actual de OHarness usa su token de cuenta de Cloud.
Modelo de datos de la sincronización de sesiones
Registro
Un registro es un elemento inmutable de una sesión. Hoy normalmente contiene un registro de transcripción o un sobre de revisión de artefacto, pero el servidor debe tratar body como JSON opaco.
{
"session": "20260914_abcd",
"id": "stable-record-id",
"seq": 12,
"device": "device-id",
"at": 1789344000000,
"body": { "opaque": "to the server" }
}
| Campo | Tipo | Significado |
|---|---|---|
session | string | Sesión que contiene el registro |
id | string | Id estable usado para eliminar duplicados en los reintentos |
seq | number | Secuencia de Lamport dentro de la sesión |
device | string | Dispositivo que creó el registro |
at | number | Momento de creación en milisegundos Unix |
body | cualquier JSON | Carga útil que se guarda y se devuelve sin cambios |
La clave de deduplicación es (session, id). Volver a enviar esa clave no debe añadir un segundo evento, aunque se perdiera una respuesta anterior. No analices, normalices ni reescribas body.
Metadatos de sesión
{
"id": "20260914_abcd",
"gen": 1,
"updatedAt": 1789344000000,
"body": { "opaque": "session metadata" }
}
body es opaco. deleted: true es una lápida (tombstone) y normalmente no tiene cuerpo. Los metadatos siguen «gana el último en escribir» usando esta tupla, en prioridad descendente:
- Gana el
genmayor. Una compactación o reescritura incrementa la generación. - Dentro de una misma generación, gana el
updatedAtmayor. - Una tupla igual ya se tiene y no añade ningún evento.
La actualización ganadora, incluida una lápida, se añade al feed para que los dispositivos que ya leyeron un valor anterior puedan ver el cambio.
Cursor
Un cursor es un entero no negativo emitido por el servidor. Identifica una posición en un feed (owner, project). El cliente lo trata como opaco: lo guarda, lo devuelve como since y solo espera que nunca retroceda.
La implementación JSONL incluida usa un número de evento incremental. Una implementación con base de datos puede usar una secuencia. No se exige que un cursor sea un desplazamiento de bytes, una marca de tiempo ni el seq de un registro.
Los cursores pertenecen a un feed y no deben reutilizarse entre usuarios, equipos ni proyectos. El almacenamiento del feed es estado duradero. Si un servidor pierde un feed mientras un cliente conserva su cursor, el cliente puede quedar permanentemente por delante del nuevo feed; la v1 del protocolo no tiene un procedimiento de reinicio de cursor.
Endpoints de sesión
GET /v1/health
Endpoint de comprobación de actividad sin autenticación.
HTTP/1.1 200 OK
Content-Type: application/json
{"ok":true}
Esto demuestra que el proceso y la ruta son accesibles. No demuestra que un token funcione, que el almacenamiento persistente esté montado ni que la memoria esté configurada.
GET /v1/projects
Lista los proyectos que tienen un feed para el propietario seleccionado.
GET /v1/projects
GET /v1/projects?team=platform
Respuesta:
{"projects":["project-a","project-b"]}
Devuelve un array vacío cuando el propietario no tiene feeds. Los ID que generan los clientes actuales de OHarness cumplen [A-Za-z0-9_-]{1,128} y deben devolverse sin cambios. Un servidor puede rechazar o codificar de forma reversible otras entradas; nunca concatenes un id no fiable en una ruta del sistema de archivos.
El espejo actual por proyecto no llama a esta ruta durante su bucle normal de envío/lectura, pero forma parte del cliente público y es obligatoria para una implementación completa y para el descubrimiento de proyectos entre dispositivos.
POST /v1/push
Añade de forma atómica los registros nuevos y los metadatos de sesión ganadores.
{
"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 y sessions son opcionales. Los arrays ausentes equivalen a arrays vacíos. Como mínimo se requiere project. Declaraciones de tipos antiguas incluían un device de nivel superior; el cliente de OHarness no lo envía y los servidores compatibles no deben exigirlo. La identidad del dispositivo va en cada registro.
Respuesta:
{"cursor":42,"accepted":2}
cursor es el final actual del feed tras este envío. accepted cuenta los eventos de registros nuevos más los eventos de metadatos de sesión ganadores. Los registros duplicados y los metadatos de sesión obsoletos o iguales no cuentan.
Toda la solicitud se puede reintentar. Valídala antes de confirmar ninguna entrada. No aceptes un lote parcial: devuelve un error para toda la solicitud si alguna entrada está mal formada o se supera un límite.
Límites que usan el cliente actual y el servidor incluido:
| Límite | Valor |
|---|---|
| Registros en un envío | 500 |
| Entradas de metadatos de sesión en un envío | 500 |
| Cuerpo de la solicitud serializado | 8 MiB |
Los dos límites de arrays son independientes: una solicitud puede contener 500 registros y 500 entradas de sesión.
GET /v1/pull
Lee los eventos del feed posteriores a un cursor.
GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
| Query | Obligatorio | Significado |
|---|---|---|
project | sí | Id de proyecto opaco |
since | sí en el uso normal | Devuelve los eventos estrictamente posteriores a este cursor; 0 empieza desde el principio |
limit | no | Tamaño de página, limitado a 1..500; 500 por defecto |
wait | no | Segundos de long polling, limitados a 0..30; 0 por defecto |
team | no | Feed del equipo en lugar del feed del principal |
Respuesta:
{
"cursor": 22,
"records": [],
"sessions": [],
"more": false
}
Devuelve los eventos en el orden del feed y sin campos exclusivos del servidor. El cursor es el último evento devuelto. Si no hay eventos nuevos, sigue siendo igual a since. Pon more: true solo cuando ya haya otra página disponible; el cliente vuelve a leer inmediatamente.
Cuando wait > 0 y el feed está al día, retén la solicitud hasta que se añada un evento o venza la espera. Un tiempo agotado es una respuesta vacía correcta, no un error HTTP. Desconectar un long poll no debe cambiar el estado del feed.
Respuestas de error de sesión
Los errores de sesión son objetos JSON con una cadena error corta y legible:
{"error":"missing or bad token"}
Usa los códigos de estado convencionales:
| Estado | Cuándo |
|---|---|
400 | JSON no válido, falta el proyecto, cursor no válido o entrada mal formada |
401 | Token Bearer ausente, caducado o no válido; incluye WWW-Authenticate: Bearer |
403 | El principal autenticado no es miembro del equipo solicitado |
413 | Los bytes de la solicitud o el número de entradas superan el límite |
404 | Ruta desconocida |
500 | Fallo inesperado del almacenamiento; no afirmes que un envío parcial tuvo éxito |
El espejo reintenta con espera progresiva los errores de red y los fallos normales del servidor. Un 401 detiene ese espejo hasta que se reinicia con una credencial utilizable.
Sincronización de la memoria
La sincronización de la memoria es opcional e independiente del feed de sesiones de solo anexado. Las tres rutas son solicitudes POST autenticadas con Content-Type: application/json:
/v1/memory/identity/v1/memory/snapshot/v1/memory/commit
El cliente actual es solo nativo. Una solicitud que lleve una cabecera Origin se rechaza con 403 {"code":"native_client_required"}.
Identidad de la memoria
Cada solicitud lleva expectedPrincipal, tomado de la cuenta de Cloud decodificada localmente como fijación del consentimiento. El servidor debe validar el token Bearer de forma independiente y exigir que el principal verificado coincida con él. Nunca autorices un prefijo de objetos solo a partir de expectedPrincipal.
Solicitud:
{"expectedPrincipal":"user-id"}
Respuesta:
{
"principal": "user-id",
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
La implementación incluida valida los tokens mediante GET /auth/v1/user de Supabase, pero una implementación compatible puede usar otro servicio de cuentas. Debe rechazar cuentas anónimas, eliminadas, caducadas o no válidas por cualquier otro motivo, y devolver el mismo id de principal estable que espera el cliente.
Ámbito y formato de las notas
El ámbito de proyecto se expresa con project y sin propiedad scope:
{"project":"project-a"}
El ámbito de usuario lleva scope: "user" y no debe contener project:
{"scope":"user"}
El protocolo exige ambos ámbitos. El adaptador R2 incluido actualmente tiene una carencia conocida en la lista de claves permitidas para el prefijo de usuario; esto no cambia el contrato del protocolo para las implementaciones independientes.
Una nota es:
{
"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 es opcional y, si está presente, es user, feedback, project o reference. updatedAt es un metadato opcional y se excluye deliberadamente del hash del contenido.
Los límites de validación forman parte del contrato del protocolo:
| Campo | Restricción |
|---|---|
| Ids de principal, proyecto y nota | [A-Za-z0-9_-]{1,128}; rechaza también __proto__, constructor y prototype |
summary | Sin espacios en los extremos, una línea, de 1 a 500 caracteres |
body | Cadena sin espacios en los extremos, como máximo el maxNoteBytes anunciado en UTF-8 |
tags | Como máximo 30; cada una de 1 a 64 caracteres, sin espacios, comas ni saltos de línea |
createdAt | Entero seguro no negativo |
updatedAt | Entero seguro no negativo opcional |
Los ids de las notas activas son únicos sin distinguir mayúsculas y minúsculas. Es obligatorio porque un servidor no debe crear un estado que los sistemas de archivos habituales de los clientes, que no distinguen mayúsculas, no puedan representar.
El hash SHA-256 de la nota, en hexadecimal en minúsculas, se calcula sobre JSON UTF-8 con este array posicional exacto:
JSON.stringify([id, summary, body, tags, createdAt])
Cuando, y solo cuando, type está presente, se añade:
JSON.stringify([id, summary, body, tags, createdAt, type])
Las implementaciones deben reproducir JSON.stringify de JavaScript para estos valores. La excepción de compatibilidad conserva los hashes creados antes de que existieran los tipos de nota.
POST /v1/memory/snapshot
Solicitud de proyecto:
{
"expectedPrincipal": "user-id",
"project": "project-a",
"knownEtag": "optional-previous-etag",
"have": {
"borderless-cards-a91": "64-lowercase-hex-characters"
}
}
Para el ámbito de usuario, sustituye project por "scope":"user".
knownEtag permite una vía rápida cuando no hay cambios. have asocia ids de notas con los hashes que el cliente ya tiene; el servidor puede omitir esos cuerpos de nota sin leerlos del almacenamiento de objetos. Rechaza un mapa have con más de maxNotes * 2 + 1000 claves.
Respuesta sin cambios:
{
"principal": "user-id",
"etag": "same-etag",
"unchanged": true,
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
Respuesta completa o incremental:
{
"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
}
}
Una entrada activa puede omitir note solo cuando have[id] es igual a su hash. Una lápida tiene hash: null y no tiene nota. etag es null cuando no existe ningún manifiesto.
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": {}
}
Para el ámbito de usuario, sustituye project por "scope":"user". Una creación o actualización debe llevar una nota cuyo id y hash calculado coincidan con el cambio. Una eliminación tiene hash: null y no tiene note. changes debe contener entre 1 y el maxChanges anunciado de entradas, sin ids duplicados.
Aplica el commit solo cuando etag siga identificando el manifiesto actual. Ante un ETag obsoleto, devuelve 409 {"code":"revision_conflict"} y deja sin cambios el manifiesto publicado. Escribe las nuevas revisiones inmutables de las notas antes de publicar el nuevo manifiesto, y publica el manifiesto de forma atómica. Un escritor concurrente que pierde puede dejar objetos inmutables inaccesibles para una recolección de basura posterior, pero ningún estado parcial puede hacerse visible a través del manifiesto. La respuesta tiene la misma forma que una instantánea correcta, con el nuevo ETag y las entradas actuales.
No descartes las eliminaciones de inmediato. Conserva las lápidas durante la ventana anunciada para que los clientes sin conexión puedan verlas. Bajar una cuota no debe dejar atrapado a un principal por encima de ella: rechaza el crecimiento por encima de maxNotes, pero sigue permitiendo eliminaciones.
Errores y límites de la memoria
Los errores de la memoria usan un code estable y legible por máquinas:
{"code":"revision_conflict"}
Los códigos que usa la implementación actual incluyen:
| Estado | Códigos |
|---|---|
400 | invalid_body, unknown_field, invalid_project, invalid_scope, invalid_have, invalid_changes, invalid_note, hash_mismatch, id_case_conflict |
401 | sign_in_required |
403 | native_client_required, account_mismatch |
409 | revision_conflict |
413 | memory_quota_exceeded |
415 | json_required |
429 | rate_limited |
502 | auth_unavailable, invalid_manifest, missing_memory_object, memory_integrity_error, memory_sync_failed |
503 | auth_not_configured, r2_not_configured |
Los valores predeterminados incluidos son 5000 notas activas por ámbito, 500 cambios por commit, 128 KiB por cuerpo de nota, 4 MiB por solicitud, 16 MiB de datos de entradas serializados por respuesta y 90 días para las lápidas. Solo los cuatro valores sobre los que el cliente puede actuar, mostrados en quota, se anuncian en el protocolo. Un cliente también aplica sus propios topes de seguridad estrictos, así que subir las cuotas del servidor no garantiza que un cliente acepte una respuesta arbitrariamente grande.
Configurar un cliente de OHarness
Pon el destino en la configuración del usuario, nunca en la configuración de un repositorio:
{
"sync": {
"url": "https://sync.example.com",
"token": "opaque-session-token",
"team": "optional-team",
"enabled": true
}
}
El archivo es $OHARNESS_HOME/config.json cuando OHARNESS_HOME está definida, $XDG_CONFIG_HOME/oharness/config.json en Linux cuando XDG está definido, y ~/.oharness/config.json en los demás casos. Reinicia OHarness después de cambiar el destino.
La precedencia actual es:
sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token
Por tanto, OHARNESS_SYNC_URL solo sustituye el valor predeterminado fijo cuando la configuración de usuario no tiene sync.url. OHARNESS_SYNC=0 desactiva la sincronización de sesiones sea cual sea el destino o la credencial.
Los archivos oharness.config.json y .oharness.json de un repositorio no pueden definir sync.url, sync.token ni sync.team; OHarness descarta esos valores. Un repositorio solo puede desactivar la sincronización o añadir exclusiones. Así se evita que un repositorio clonado redirija las transcripciones de un usuario.
La misma URL base resuelta se usa para la memoria, pero el cliente de memoria actual usa la credencial de la cuenta de Cloud, no sync.token ni OHARNESS_SYNC_TOKEN. Para autoalojar la memoria, el inicio de sesión del cliente y el verificador de identidad del servidor de memoria deben, por tanto, coincidir en el mismo principal.
Lista de comprobación de implementación
Un servicio compatible debe cumplir todos estos comportamientos:
- La comprobación de estado funciona sin token; todas las rutas de datos fallan en cerrado sin él.
- Dos principales que usan el mismo id de proyecto no pueden ver los datos del otro.
- Los feeds personales y de equipo son distintos; los equipos no autorizados devuelven
403. - Reintentar un envío no duplica registros ni metadatos de sesión obsoletos.
- La lectura es ordenada, paginada, con cursor monótono y segura para long polling.
- Los cuerpos de sesión sobreviven a un envío/lectura sin cambios a nivel de valor JSON.
- Las eliminaciones de sesiones y de la memoria llegan a un segundo dispositivo sin conexión.
- Un ETag de memoria obsoleto no publica ningún cambio en el manifiesto.
- Las claves de objetos de la memoria siempre usan el principal verificado por el servidor.
- Las discrepancias de hash, las colisiones de ids de nota que solo difieren en mayúsculas y las solicitudes demasiado grandes se rechazan antes de publicar un manifiesto.
- El estado persistente del feed y del manifiesto sobrevive a los reinicios del proceso y a los despliegues.
Las pruebas de sesión del repositorio en test/sync.test.ts y las pruebas de memoria en script/sync-memory.test.ts son ejemplos ejecutables del contrato. Actualmente instancian directamente el servidor incluido en lugar de aceptar una URL remota, así que otra implementación debería portar los mismos casos o aportar un pequeño adaptador antes de declararse compatible.
Para una primera prueba de humo remota, emite un token desechable y sustituye su valor abajo. No uses un token de cuenta de producción en el historial de una shell:
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"
Envía el mismo cuerpo una segunda vez y comprueba que accepted es 0; luego lee desde el cursor devuelto y comprueba que los arrays están vacíos y que el cursor no ha cambiado. Estas comprobaciones establecen la accesibilidad y la idempotencia básica, no el aislamiento entre inquilinos ni la seguridad de CAS concurrente.
Lo que no se exige
Un servidor compatible no necesita:
- ejecutar un modelo, ejecutar herramientas ni entender los formatos de mensaje de las transcripciones;
- usar JSONL, R2, Supabase ni ninguna base de datos concreta;
- ofrecer registro, inicio de sesión, emisión de tokens ni una aplicación de navegador;
- implementar la memoria para implementar la sincronización de sesiones;
- exponer CORS. Los clientes actuales son clientes nativos Node/Electron.
Mantener estas cuestiones fuera del protocolo es lo que permite que un pequeño servicio autoalojado y un despliegue multiinquilino hablen el mismo formato de transmisión.