Sincronización autoalojada

La sincronización es la mitad opcional de OHarness: guarda las conversaciones para que una sesión iniciada en un equipo se pueda reanudar en otro. El cliente reserva https://sync.oharness.ai como su destino oficial predeterminado, pero ese servicio es una comodidad y no una dependencia: el servidor es el paquete npm @oharness/sync, publicado con licencia MIT, y apuntar un harness a tu propia copia son dos líneas de configuración.

Nada de esto es obligatorio. Un harness sin sesión iniciada y sin token autoalojado nunca abre una conexión de sincronización, y el almacén local de sesiones está completo por sí solo. Iniciar sesión activa la sincronización por defecto y usa el destino oficial salvo que el usuario lo cambie o desactive la sincronización.

Qué es el servidor y qué no es

Guarda blobs opacos y los devuelve en orden. Nunca ejecuta un modelo, nunca ejecuta nada y nunca mira dentro de lo que guarda: el servidor no toca body en la transmisión (packages/engine/src/sync/protocol.ts).

Esa es la propiedad que hay que proteger, no una descripción de la implementación actual. Es lo que hace seguro dejar el servidor en marcha, lo que permite que el formato de los mensajes cambie sin volver a desplegar, y lo que haría que un modo con cifrado de extremo a extremo fuera solo un cambio en el cliente. Una función que necesite que este servicio analice una transcripción es una función que pertenece a otro sitio.

Dos cosas que deliberadamente no es:

No es un sistema de cuentas. No hay endpoint de registro, porque un servidor de sincronización que puede crear cuentas es un servidor de sincronización con un endpoint de registro que defender. Los tokens se emiten por otro canal; consulta más abajo.

No es el servidor del agente. Ese tiene una shell y es de un solo inquilino por diseño. Consulta railway.agent.toml.

Inicio rápido

npx @oharness/sync token alice        # printed once, stored as a hash
npx @oharness/sync --port 4483        # serve

Después, en ~/.oharness/config.json:

{
  "sync": {
    "url": "https://sync.example.com",
    "token": "<the token printed above>"
  }
}

Cuando la configuración de usuario no tiene sync.url, OHARNESS_SYNC_URL cambia el destino sin editar ningún archivo. Si ambos están presentes, la configuración de usuario gana deliberadamente. OHARNESS_SYNC=0 desactiva la sincronización para una ejecución o para toda la CI. Reinicia OHarness después de cambiar el destino.

La URL se valida en el cliente: HTTPS es obligatorio, y HTTP sin cifrar solo se permite para desarrollo en loopback. Las credenciales incrustadas, una query o un fragmento se rechazan en lugar de eliminarse.

El protocolo de transmisión

La sincronización de sesiones tiene cuatro endpoints. La memoria añade tres endpoints opcionales. Todo salvo /v1/health requiere Authorization: Bearer.

RutaPropósito
GET /v1/healthComprobación de actividad. Sin autenticación a propósito: una comprobación de estado que necesita una credencial es una comprobación que nadie llega a configurar
GET /v1/projectsIds de proyecto a los que se ha enviado algo, bajo un mismo propietario
POST /v1/pushAñade registros y metadatos de sesión; devuelve el nuevo cursor y cuántos eran nuevos
GET /v1/pullLee hacia delante desde un cursor; wait=<seconds> hace long polling
POST /v1/memory/identityComprobación opcional de identidad y cuota de la memoria
POST /v1/memory/snapshotManifiesto opcional de la memoria y cuerpos de las notas modificadas
POST /v1/memory/commitActualización opcional de la memoria con compare-and-swap

Un feed solo admite anexado y un cursor es un entero opaco y monótono emitido por el servidor. El almacén JSONL incluido usa un número de evento y mantiene aparte puntos de control por desplazamiento de bytes; otra implementación puede usar una secuencia de base de datos. Los envíos eliminan duplicados por (session, id), así que un reintento tras una respuesta perdida no cuesta nada en lugar de duplicar un turno. Las sesiones se resuelven por generación con «gana el último en escribir», y una eliminación viaja como lápida (tombstone): sin ella, el otro dispositivo restaura lo que eliminaste.

El contrato completo de interoperabilidad, incluidas las formas JSON de solicitud y respuesta, la semántica del cursor, los errores, el hashing de la memoria y una lista de comprobación de implementación, está en Protocolo de sincronización. Lee esa página si vas a construir un servicio compatible en lugar de desplegar @oharness/sync.

Los límites están en packages/engine/src/sync/protocol.ts y los comparten ambas mitades, porque un cliente y un servidor que tienen cada uno su propia idea del protocolo divergen, y esa divergencia aparece como registros que no se fusionan sin avisar:

MAX_PUSH_RECORDS500 entradas por envío
MAX_BODY_BYTES8 MiB por solicitud
DEFAULT_PULL_LIMIT500 entradas por lectura
MAX_WAIT_SECONDS30 s, por debajo de cualquier tiempo de inactividad razonable de un proxy

Un envío demasiado grande se rechaza con 413 en lugar de truncarse: aceptar medio lote e informar de éxito perdería turnos que el cliente da entonces por entregados.

Los feeds se escriben en <root>/feed/<kind>-<owner>/<project>.jsonl, donde --root es por defecto ~/.oharness/sync. En una plataforma que da a los contenedores un sistema de archivos efímero, ponlo en un volumen montado. Perderlo no es solo vaciar una caché: todos los clientes se quedan con un cursor que apunta a un feed que ya no existe.

Dos formas de tener un token

Emitido localmente. oharness-sync token <user> escribe un users.json junto a los feeds con el id de usuario y un SHA-256 del token; el token en sí nunca se guarda y se imprime una sola vez. Una entrada es el caso de un solo usuario y no cuesta nada. No necesita ningún otro servicio y es el modo que debes usar salvo que ya tengas un sistema de cuentas.

Firmado por otro sistema. Define OHARNESS_SYNC_JWT_SECRET (HS256) o OHARNESS_SYNC_JWKS_URL (asimétrico) y el servidor también acepta tokens firmados por tu sistema de autenticación, verificados por la firma en lugar de preguntando a ese sistema, de modo que nada en la ruta de la solicitud depende de que otro servicio esté activo. sub pasa a ser el id del propietario. OHARNESS_SYNC_JWT_ISSUER y OHARNESS_SYNC_JWT_AUDIENCE se aplican cuando están definidos.

Las dos formas se distinguen por su forma: un token emitido localmente son 32 bytes aleatorios en base64url y nunca puede contener un punto. Activar JWT no desactiva users.json; ambos siguen funcionando.

Equipos

Un equipo es un feed compartido. Todas las personas que reciben un token con el mismo id de equipo leen y escriben un único historial por proyecto, que es lo que permite a dos personas continuar las sesiones de la otra.

npx @oharness/sync token bob --team platform

Con tokens firmados, los ids vienen de un claim teams, que se puede renombrar con OHARNESS_SYNC_TEAMS_CLAIM. La pertenencia se registra en el token en lugar de consultarse en cada solicitud: la alternativa pone la disponibilidad de otro por delante de cada escritura, en el endpoint que existe para que las transcripciones sean duraderas.

Indicar un equipo que el token no tiene se rechaza con 403 en lugar de responder discretamente con el feed propio de quien llama. Esa sustitución el usuario la lee como «el equipo no tiene nada», que es la única respuesta errónea sobre la que actuaría.

La memoria necesita más que el feed

/v1/memory/* lo sirve el mismo proceso, pero no es el protocolo del feed: es un manifiesto actualizado con compare-and-swap más objetos Markdown inmutables en un bucket privado, con tres acciones: identity, snapshot y commit.

Tiene requisitos que el protocolo del feed no tiene, y conviene conocerlos antes de planificar en torno a ellos:

OHARNESS_SYNC_AUTH_URL     Supabase project origin, bare — no path or query
OHARNESS_SYNC_AUTH_KEY     publishable/anon key, never a service role
R2_ACCOUNT_ID              Cloudflare account id, 32 hex characters
R2_MEMORY_BUCKET           private bucket, public access disabled
R2_ACCESS_KEY_ID           bucket-scoped Object Read & Write credential
R2_SECRET_ACCESS_KEY       matching secret

La autenticación tiene forma de Supabase, no es genérica. El protocolo del feed acepta cualquier JWKS al que lo apuntes; la memoria valida un token llamando a /auth/v1/user en el origen configurado, que es la API de Supabase y no un estándar OIDC. Si autoalojas con otro proveedor de identidad, los feeds de sesión funcionarán y la memoria no.

La distinción también se aplica a las credenciales emitidas localmente: oharness-sync token <user> basta para los feeds de sesión, pero el cliente de memoria actual envía su token de cuenta de Cloud en lugar de sync.token o OHARNESS_SYNC_TOKEN. Por tanto, un despliegue autoalojado de la memoria necesita un inicio de sesión de Cloud y un verificador de identidad que resuelvan ese token al mismo principal.

El almacenamiento es compatible con S3, direccionado como R2. Las claves son users/<verified-user-id>/projects/<project-id>/memory/ para las notas de proyecto y users/<verified-user-id>/memory/ para las notas propias de un usuario, con el id de usuario tomado del token verificado y no de nada que envíe el cliente.

Limitación actual del adaptador. El protocolo y la API de memoria admiten ambos prefijos, pero la lista de claves permitidas del createR2MemoryStore incluido en esta revisión del código todavía solo acepta el prefijo de proyecto. Amplía esa lista y añade una prueba con el adaptador real antes de activar la memoria de ámbito de usuario en R2; una implementación independiente debería admitir ambos prefijos descritos por el protocolo.

La configuración que falta no avisa. El cliente de almacenamiento se construye en la primera solicitud de memoria y no al arrancar, así que un despliegue al que le falta una variable parece totalmente sano: el puerto se abre, /v1/health responde, los feeds se sincronizan y la memoria devuelve 503 en el cliente del usuario, que es el único sitio que nadie que despliegue esto está mirando. El servidor imprime una línea al arrancar que nombra las variables que falten:

memory off (no Memory environment)          # nothing set: the ordinary case
!  memory off — missing R2_SECRET_ACCESS_KEY # some set: a mistake worth finding
memory my-bucket · auth project.supabase.co · 5000 notes · 90d tombstones  # configured

No definir ninguna de las seis es el caso autoalojado habitual, y lo indica discretamente. Definir solo algunas es el caso que merece una marca en el margen. Revisa el log del despliegue; memory off ahí es todo el aviso.

Las cuotas las fijas tú. Todas son opcionales. Las respuestas anuncian los cuatro límites sobre los que el cliente puede actuar; los límites de bytes de solicitud y respuesta siguen siendo cotas de seguridad del lado del servidor:

OHARNESS_SYNC_MEMORY_MAX_NOTES           live notes per project and per user (5000)
OHARNESS_SYNC_MEMORY_MAX_CHANGES         changes per commit (500)
OHARNESS_SYNC_MEMORY_MAX_NOTE_BYTES      bytes per note body (131072)
OHARNESS_SYNC_MEMORY_MAX_RESPONSE_BYTES  note bytes per response (16777216)
OHARNESS_SYNC_MEMORY_MAX_REQUEST_BYTES   request body bytes (4194304)
OHARNESS_SYNC_MEMORY_TOMBSTONE_DAYS      days a deletion is kept (90)

Una cuota bloquea el crecimiento, nunca la eliminación, así que bajar una no puede dejar atrapado a un usuario por encima de ella. Una solicitud limitada por tasa recibe 429 con Retry-After, los segundos que quedan en la ventana de un minuto de la cuenta.

Los planes restringen la cuota. Las cuentas gratuitas reciben los límites de packages/sync/src/memory-entitlements.json, leídos del claim ent que escribe el hook de token de acceso del Cloud oficial. Si tu sistema de autenticación no escribe ese claim, todas las cuentas parecen gratuitas; define esto para dar a todas las cuentas los límites de pago:

OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off

Un valor que no sea un entero positivo se ignora y se nombra en la línea de arranque. Despliega un servidor nuevo antes que los clientes nuevos: los servidores antiguos rechazan los campos que envían los clientes nuevos para las notas de nivel de usuario y con tipo.

El comportamiento de la memoria en el cliente —notas locales primero, el interruptor de sincronización por directorio, la conciliación a tres bandas y la gestión de conflictos— tiene su propia página.

Lo que un proyecto no puede decidir

sync.url, sync.token y sync.team se descartan de la configuración del proyecto al fusionar, y no es un descuido: nombrar el servidor de sincronización dentro de un repositorio significa que un repositorio puede enviar tus transcripciones a otro sitio. Solo se respetan desde la configuración de usuario (~/.oharness/config.json) y el entorno.

La consecuencia práctica al autoalojar para un equipo: no puedes incluir la dirección de tu servidor en un oharness.config.json confirmado en el repositorio y que todo el mundo la recoja. Cada persona la define una vez en su configuración de usuario, o distribuyes OHARNESS_SYNC_URL mediante lo que ya configure su shell.

Un proyecto sí puede desactivar la sincronización, añadir a exclude y añadir a deny: restringir está permitido, relajar no. La lógica es projectSafe() en packages/engine/src/config.ts.

Notas operativas

Termina TLS delante de él. El servidor habla HTTP sin cifrar y autentica con un token bearer. Un token enviado en claro por una red que no controlas es un token que ahora tienen todos los que están en esa ruta. Enlázalo más allá de loopback (--host 0.0.0.0) solo detrás de un proxy que controles.

Aquí un límite entre inquilinos es un prefijo de clave, no un contenedor. La multitenencia es un único proceso que aísla a los usuarios por directorio de feed. Es sólido para un equipo cuyos miembros ya confían entre sí, y no es el modelo adecuado si tus inquilinos no lo hacen.

Los reinicios son baratos. El servidor no guarda en memoria nada que un cliente no pueda volver a obtener a partir de su cursor, y un long poll interrumpido se reintenta. Reinicia sin miedo; solo no pierdas --root.

No lee ningún archivo de configuración. Lo que lo despliegue —el entorno de un contenedor, una unidad de systemd, el almacén de variables de una plataforma— es la única fuente de configuración, porque un servidor de sincronización que lee un archivo del directorio de trabajo es un servidor cuyo destino puede cambiar un repositorio.

railway.sync.toml en la raíz del repositorio es un ejemplo práctico de todo lo anterior como un único despliegue.