Zelfgehoste synchronisatie

Synchronisatie is de optionele helft van OHarness: ze bewaart gesprekken, zodat je een sessie die op de ene machine is gestart, op een andere kunt hervatten. De client reserveert https://sync.oharness.ai als officiële standaard, maar die dienst is een gemak en geen afhankelijkheid — de server is het npm-pakket @oharness/sync, gepubliceerd onder de MIT-licentie, en een harness naar je eigen kopie laten wijzen kost twee regels configuratie.

Niets hiervan is verplicht. Een uitgelogd harness zonder zelfgehost token opent nooit een synchronisatieverbinding, en de lokale sessieopslag is op zichzelf compleet. Inloggen zet synchronisatie standaard aan en gebruikt de officiële bestemming, tenzij de gebruiker die overschrijft of synchronisatie uitzet.

Wat de server is, en wat niet

Hij bewaart ondoorzichtige blobs en geeft ze op volgorde terug. Hij draait nooit een model, voert nooit iets uit en kijkt nooit in wat hij bewaart — body op de lijn wordt door de server niet aangeraakt (packages/engine/src/sync/protocol.ts).

Dat is een eigenschap om te beschermen, niet een beschrijving van de huidige implementatie. Het maakt de server veilig om te laten draaien, laat het berichtformaat veranderen zonder nieuwe deploy, en zou van een end-to-end versleutelde modus een wijziging aan alleen de clientkant maken. Een functie waarvoor deze dienst een transcriptie moet parsen, hoort ergens anders thuis.

Twee dingen is hij bewust niet:

Het is geen accountsysteem. Er is geen registratie-endpoint, want een synchronisatieserver die accounts kan aanmaken, is een synchronisatieserver met een registratie-endpoint dat verdedigd moet worden. Tokens worden buiten de server om uitgegeven — zie hieronder.

Het is niet de agentserver. Die heeft een shell en is per ontwerp single-tenant. Zie railway.agent.toml.

Snel aan de slag

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

Daarna, in ~/.oharness/config.json:

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

Heeft de gebruikersconfiguratie geen sync.url, dan verplaatst OHARNESS_SYNC_URL de bestemming zonder dat je een bestand bewerkt. Zijn beide aanwezig, dan wint de gebruikersconfiguratie bewust. OHARNESS_SYNC=0 zet synchronisatie uit voor één run of voor heel CI. Herstart OHarness nadat je de bestemming hebt gewijzigd.

De URL wordt op de client gevalideerd: HTTPS is verplicht, gewone HTTP mag alleen voor ontwikkeling via loopback. Ingebedde inloggegevens, een query of een fragment worden geweigerd in plaats van weggehaald.

Het protocol op de lijn

Sessiesynchronisatie heeft vier endpoints. Geheugen voegt er drie optionele aan toe. Alles behalve /v1/health vereist Authorization: Bearer.

RouteDoel
GET /v1/healthLiveness. Bewust zonder authenticatie — een healthcheck die een credential nodig heeft, is een healthcheck die niemand aansluit
GET /v1/projectsProject-id's waarnaar ooit iets is gepusht, onder één eigenaar
POST /v1/pushRecords en sessiemetadata toevoegen; geeft de nieuwe cursor terug en hoeveel er nieuw waren
GET /v1/pullVooruit lezen vanaf een cursor; wait=<seconds> doet long polling
POST /v1/memory/identityOptionele controle van geheugenidentiteit en quotum
POST /v1/memory/snapshotOptioneel geheugenmanifest en gewijzigde notitie-inhoud
POST /v1/memory/commitOptionele compare-and-swap-update van het geheugen

Een feed is append-only en een cursor is een ondoorzichtig, monotoon geheel getal dat de server uitgeeft. De meegeleverde JSONL-opslag gebruikt een eventnummer en houdt daarnaast checkpoints met byte-offsets bij; een andere implementatie kan een databasesequentie gebruiken. Pushes worden ontdubbeld op (session, id), dus een nieuwe poging na een verloren antwoord kost niets in plaats van een dubbele beurt. Sessies worden per generatie opgelost met last-writer-wins, en een verwijdering reist mee als tombstone — zonder tombstone herstelt het andere apparaat wat je hebt verwijderd.

Het volledige interoperabiliteitscontract, met de vorm van JSON-verzoeken en -antwoorden, cursorsemantiek, fouten, geheugenhashing en een checklist voor implementaties, staat in Synchronisatieprotocol. Lees die pagina als je een compatibele dienst bouwt in plaats van @oharness/sync te deployen.

De limieten staan in packages/engine/src/sync/protocol.ts en worden door beide helften gedeeld, want een client en een server die elk hun eigen idee van het protocol hebben, lopen uit elkaar, en dat uit elkaar lopen zie je pas als records die stilletjes niet samenvoegen:

MAX_PUSH_RECORDS500 vermeldingen per push
MAX_BODY_BYTES8 MiB per verzoek
DEFAULT_PULL_LIMIT500 vermeldingen per pull
MAX_WAIT_SECONDS30 s, onder elke redelijke idle-time-out van een proxy

Een te grote push wordt geweigerd met 413 in plaats van ingekort: de helft van een batch accepteren en succes melden, zou beurten kwijtraken die de client dan als afgeleverd beschouwt.

Feeds worden geschreven naar <root>/feed/<kind>-<owner>/<project>.jsonl, waarbij --root standaard ~/.oharness/sync is. Op een platform dat containers een tijdelijk bestandssysteem geeft, zet je dat op een gemount volume. Het kwijtraken wist niet alleen een cache — elke client houdt dan een cursor vast in een feed die niet meer bestaat.

Twee manieren om een token te hebben

Lokaal uitgegeven. oharness-sync token <user> schrijft naast de feeds een users.json met de gebruikers-id en een SHA-256 van het token; het token zelf wordt nooit opgeslagen en één keer getoond. Eén vermelding is het geval met één gebruiker en kost niets. Hiervoor is geen andere dienst nodig, en dit is de modus om te gebruiken tenzij je al een accountsysteem hebt.

Door iets anders ondertekend. Zet OHARNESS_SYNC_JWT_SECRET (HS256) of OHARNESS_SYNC_JWKS_URL (asymmetrisch), dan accepteert de server ook tokens die je authenticatiesysteem heeft ondertekend, geverifieerd via de handtekening in plaats van door dat systeem te bevragen — zodat niets op het verzoekpad ervan afhangt dat een andere dienst draait. sub wordt de eigenaar-id. OHARNESS_SYNC_JWT_ISSUER en OHARNESS_SYNC_JWT_AUDIENCE worden afgedwongen als ze zijn gezet.

De twee vormen worden op hun vorm onderscheiden: een lokaal uitgegeven token is 32 willekeurige bytes in base64url en kan nooit een punt bevatten. JWT aanzetten zet users.json niet uit; beide blijven werken.

Teams

Een team is een gedeelde feed. Iedereen die een token met dezelfde team-id heeft gekregen, leest en schrijft één geschiedenis per project, en daardoor kunnen twee mensen elkaars sessies voortzetten.

npx @oharness/sync token bob --team platform

Bij ondertekende tokens komen de id's uit een teams-claim, te hernoemen met OHARNESS_SYNC_TEAMS_CLAIM. Lidmaatschap wordt vastgelegd bij het token in plaats van per verzoek opgevraagd — het alternatief zet de beschikbaarheid van iemand anders vóór elke schrijfactie, op het endpoint dat juist bestaat om transcripties duurzaam te maken.

Een team noemen dat het token niet heeft, wordt geweigerd met 403 in plaats van stilletjes beantwoord met de eigen feed van de aanroeper. Die vervanging leest voor de gebruiker als „het team is leeg”, en dat is precies het foute antwoord waarnaar hij zou handelen.

Geheugen heeft meer nodig dan de feed

/v1/memory/* wordt door hetzelfde proces geserveerd, maar is niet het feedprotocol: een manifest dat met compare-and-swap wordt bijgewerkt, plus onveranderlijke Markdown-objecten in een privébucket, met drie acties — identity, snapshot en commit.

Het heeft eisen die het feedprotocol niet heeft, en die zijn goed om te kennen voordat je ermee gaat plannen:

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

Authenticatie is op Supabase toegesneden, niet generiek. Het feedprotocol accepteert elke JWKS waarnaar je het laat wijzen; geheugen valideert een token door /auth/v1/user op de geconfigureerde origin aan te roepen, en dat is de API van Supabase en geen OIDC-standaard. Host je zelf met een andere identiteitsprovider, dan werken sessiefeeds wel en geheugen niet.

Het onderscheid geldt ook voor lokaal uitgegeven inloggegevens: oharness-sync token <user> is genoeg voor sessiefeeds, maar de huidige geheugenclient stuurt zijn Cloud-accounttoken in plaats van sync.token of OHARNESS_SYNC_TOKEN. Een zelfgehoste geheugendeployment heeft daarom een Cloud-login nodig en een identiteitsverifier die dat token naar dezelfde principal herleidt.

Opslag is S3-compatibel, aangesproken als R2. De sleutels zijn users/<verified-user-id>/projects/<project-id>/memory/ voor projectnotities en users/<verified-user-id>/memory/ voor de eigen notities van een gebruiker, waarbij de gebruikers-id uit het geverifieerde token komt en niet uit iets wat de client stuurt.

Huidige beperking van de adapter. Het protocol en de geheugen-API ondersteunen beide prefixen, maar de sleutel-allowlist van de meegeleverde createR2MemoryStore accepteert in deze bronrevisie nog alleen het projectprefix. Breid die allowlist uit en voeg een test met de echte adapter toe voordat je geheugen op gebruikersniveau op R2 aanzet; een onafhankelijke implementatie hoort beide prefixen uit het protocol te ondersteunen.

Ontbrekende configuratie is stil. De opslagclient wordt bij het eerste geheugenverzoek opgebouwd en niet bij het opstarten, dus een deploy die één variabele kwijt is, ziet er volkomen gezond uit — de poort gaat open, /v1/health antwoordt, feeds synchroniseren, en geheugen geeft 503 terug in de client van de gebruiker, precies de plek waar niemand die dit deployt naar kijkt. De server toont bij het opstarten één regel die de ontbrekende variabelen noemt:

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

Geen van de zes zetten is het gewone zelfgehoste geval en wordt rustig gemeld. Een deel ervan zetten is het geval dat een aantekening in de kantlijn waard is. Controleer het deploylog; memory off daar is de hele waarschuwing.

Quota stel je zelf in. Elk ervan is optioneel. Antwoorden melden de vier limieten waar de client iets mee kan; bytelimieten voor verzoeken en antwoorden blijven veiligheidsgrenzen aan de serverkant:

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)

Een quotum blokkeert groei, nooit verwijdering, dus een quotum verlagen kan een gebruiker die erboven zit niet vastzetten. Een verzoek dat tegen de rate limit aanloopt, krijgt 429 met Retry-After: de seconden die nog over zijn in het venster van één minuut van het account.

Abonnementen perken het quotum in. Gratis accounts krijgen de limieten uit packages/sync/src/memory-entitlements.json, gelezen uit de ent-claim die de access-token-hook van de officiële Cloud schrijft. Schrijft jouw authenticatie zo'n claim niet, dan lijkt elk account gratis; zet dit om elk account in plaats daarvan betaalde limieten te geven:

OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off

Een waarde die geen positief geheel getal is, wordt genegeerd en in de opstartregel genoemd. Deploy een nieuwe server vóór nieuwe clients: oudere servers weigeren de velden die nieuwere clients sturen voor notities op gebruikersniveau en getypeerde notities.

Het gedrag van geheugen aan de clientkant — local-first notities, de synchronisatieschakelaar per map, three-way reconciliatie en het afhandelen van conflicten — staat op een eigen pagina.

Wat een project niet kan bepalen

sync.url, sync.token en sync.team worden bij het samenvoegen uit de projectconfiguratie weggegooid, en dat is geen vergissing: de synchronisatieserver binnen een repository noemen, betekent dat een repository je transcripties ergens anders heen kan sturen. Ze worden alleen gehonoreerd vanuit de gebruikersconfiguratie (~/.oharness/config.json) en de omgeving.

Het praktische gevolg als je voor een team zelf host: je kunt het adres van je server niet meeleveren in een ingecheckte oharness.config.json en iedereen het laten oppikken. Ieder stelt het één keer in zijn gebruikersconfiguratie in, of je verspreidt OHARNESS_SYNC_URL via wat hun shell al configureert.

Een project kan synchronisatie nog steeds uitzetten, aanvullen in exclude en aanvullen in deny — aanscherpen mag, versoepelen niet. De logica is projectSafe() in packages/engine/src/config.ts.

Operationele aantekeningen

Laat TLS ervoor afhandelen. De server spreekt gewone HTTP en authenticeert met een bearer-token. Een token dat onversleuteld wordt verstuurd over een netwerk dat je niet beheert, is een token dat nu iedereen op dat pad heeft. Bind breder dan loopback (--host 0.0.0.0) alleen achter een proxy die van jou is.

Een tenantgrens is hier een sleutelprefix, geen container. Multi-tenancy is één proces dat gebruikers isoleert per feedmap. Dat is degelijk voor een team waarvan de leden elkaar al vertrouwen, en niet het model om te kiezen als je tenants dat niet doen.

Herstarten is goedkoop. De server houdt niets in het geheugen wat een client niet uit zijn cursor kan herleiden, en een afgebroken long poll wordt opnieuw geprobeerd. Herstart gerust; raak alleen --root niet kwijt.

Hij leest geen configuratiebestand. Wat hem ook deployt — de omgeving van een container, een systemd-unit, de variabelenopslag van een platform — is de enige bron van configuratie, want een synchronisatieserver die een bestand uit de werkmap leest, is een synchronisatieserver waarvan een repository de bestemming kan veranderen.

railway.sync.toml in de root van de repository is een uitgewerkt voorbeeld van al het bovenstaande als één deployment.