Egen synkserver

Synkronisering är den valfria halvan av OHarness: den lagrar konversationer så att en session som startats på en dator kan återupptas på en annan. Klienten har https://sync.oharness.ai som officiell standard, men den tjänsten är en bekvämlighet snarare än ett beroende — servern är npm-paketet @oharness/sync, publicerat under MIT-licensen, och att peka en harness mot din egen kopia är två rader konfiguration.

Inget här krävs. En utloggad harness utan egen synktoken öppnar aldrig en synkanslutning, och det lokala sessionslagret är komplett på egen hand. Att logga in slår som standard på synkronisering och använder den officiella destinationen om inte användaren åsidosätter den eller stänger av synkroniseringen.

Vad servern är, och vad den inte är

Den lagrar opaka blobbar och lämnar tillbaka dem i ordning. Den kör aldrig en modell, kör aldrig något och tittar aldrig in i det den lagrar — body på tråden rörs inte av servern (packages/engine/src/sync/protocol.ts).

Det är egenskapen som ska skyddas, inte en beskrivning av den nuvarande implementationen. Det är den som gör servern säker att låta köra, som låter meddelandeformatet ändras utan en ny driftsättning, och som skulle göra ett läge med end-to-end-kryptering till enbart en ändring på klientsidan. En funktion som kräver att den här tjänsten tolkar ett transkript hör hemma någon annanstans.

Två saker den medvetet inte är:

Den är inget kontosystem. Det finns ingen registrerings-endpoint, eftersom en synkserver som kan skapa konton är en synkserver med en registrerings-endpoint att försvara. Tokens utfärdas vid sidan av — se nedan.

Den är inte agentservern. Den håller ett skal och är single-tenant till sin konstruktion. Se railway.agent.toml.

Snabbstart

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

Sedan, i ~/.oharness/config.json:

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

När användarkonfigurationen saknar sync.url flyttar OHARNESS_SYNC_URL destinationen utan att du behöver ändra en fil. Användarkonfigurationen vinner medvetet när båda finns. OHARNESS_SYNC=0 stänger av synkroniseringen för en körning eller för hela CI. Starta om OHarness efter att du bytt destination.

URL:en valideras på klienten: HTTPS krävs, och vanlig HTTP tillåts bara för utveckling mot loopback. Inbäddade inloggningsuppgifter, en query eller ett fragment avvisas i stället för att tas bort.

Protokollet på tråden

Sessionssynkronisering har fyra endpoints. Minnet lägger till tre valfria endpoints. Allt utom /v1/health kräver Authorization: Bearer.

RouteSyfte
GET /v1/healthLiveness. Oautentiserad med avsikt — en hälsokontroll som kräver en inloggningsuppgift är en hälsokontroll som ingen kopplar in
GET /v1/projectsProjekt-id:n som något har pushats till, under en ägare
POST /v1/pushLägger till poster och sessionsmetadata; returnerar den nya cursorn och hur många som var nya
GET /v1/pullLäser framåt från en cursor; wait=<seconds> gör long polling
POST /v1/memory/identityValfri kontroll av identitet och kvot för minnet
POST /v1/memory/snapshotValfritt manifest för minnet och ändrade anteckningsinnehåll
POST /v1/memory/commitValfri compare-and-swap-uppdatering av minnet

Ett flöde är append-only och en cursor är ett opakt, monotont heltal som servern utfärdar. Det medföljande JSONL-lagret använder ett händelsenummer och underhåller separat kontrollpunkter för byte-offset; en annan implementation kan använda en databassekvens. Pushar dedupliceras på (session, id), så ett nytt försök efter ett tappat svar kostar inget i stället för att ge en dubblerad tur. Sessioner avgörs med last-writer-wins per generation, och en radering färdas som en tombstone — utan en sådan återställer den andra enheten det du raderade.

Det fullständiga kontraktet för interoperabilitet, inklusive JSON-format för förfrågningar och svar, cursorsemantik, fel, hashning av minnet och en checklista för implementation, finns i Synkprotokoll. Läs den sidan om du bygger en kompatibel tjänst i stället för att driftsätta @oharness/sync.

Gränserna finns i packages/engine/src/sync/protocol.ts och delas av båda halvorna, eftersom en klient och en server som var och en äger sin bild av protokollet glider isär, och glidningen visar sig som poster som i tysthet inte går att sammanfoga:

MAX_PUSH_RECORDS500 poster per push
MAX_BODY_BYTES8 MiB per förfrågan
DEFAULT_PULL_LIMIT500 poster per pull
MAX_WAIT_SECONDS30 s, under varje rimlig tidsgräns för inaktivitet i en proxy

En för stor push avvisas med 413 i stället för att kortas: att ta emot halva batchen och rapportera framgång skulle förlora turer som klienten sedan anser levererade.

Flöden skrivs till <root>/feed/<kind>-<owner>/<project>.jsonl, där --root som standard är ~/.oharness/sync. På en plattform som ger containrar ett tillfälligt filsystem ska det ligga på en monterad volym. Att förlora det rensar inte bara en cache — varje klient står kvar med en cursor in i ett flöde som inte längre finns.

Två sätt att hålla en token

Utfärdad lokalt. oharness-sync token <user> skriver en users.json bredvid flödena med användar-id:t och en SHA-256 av token; själva token lagras aldrig och skrivs ut en gång. En post är fallet med en användare och kostar inget. Det här kräver ingen annan tjänst och är läget att använda om du inte redan har ett kontosystem.

Signerad av något annat. Sätt OHARNESS_SYNC_JWT_SECRET (HS256) eller OHARNESS_SYNC_JWKS_URL (asymmetrisk) så accepterar servern även tokens som ditt autentiseringssystem signerat, verifierade via signaturen i stället för genom att fråga det systemet — så inget på förfrågans väg beror på att en annan tjänst är uppe. sub blir ägar-id:t. OHARNESS_SYNC_JWT_ISSUER och OHARNESS_SYNC_JWT_AUDIENCE upprätthålls när de är satta.

De två formerna skiljs åt på formen: en lokalt utfärdad token är 32 slumpmässiga bytes i base64url och kan aldrig innehålla en punkt. Att slå på JWT stänger inte av users.json; båda fortsätter att fungera.

Team

Ett team är ett delat flöde. Alla som fått en token med samma team-id läser och skriver en gemensam historik per projekt, vilket är vad som låter två personer fortsätta varandras sessioner.

npx @oharness/sync token bob --team platform

Med signerade tokens kommer id:na från ett teams-claim, som kan döpas om med OHARNESS_SYNC_TEAMS_CLAIM. Medlemskap registreras mot token i stället för att efterfrågas per förfrågan — alternativet sätter någon annans tillgänglighet framför varje skrivning, på den endpoint som finns för att göra transkript beständiga.

Att ange ett team som token inte har avvisas med 403 i stället för att i tysthet besvaras med anroparens eget flöde. Den ersättningen uppfattas av användaren som ”teamet har inget i sig”, vilket är det enda felaktiga svaret de skulle agera på.

Minnet behöver mer än flödet

/v1/memory/* betjänas av samma process men är inte flödesprotokollet: ett manifest som uppdateras med compare-and-swap plus oföränderliga Markdown-objekt i en privat bucket, med tre åtgärder — identity, snapshot och commit.

Det har krav som flödesprotokollet inte har, och de är värda att känna till innan du planerar utifrån dem:

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

Autentiseringen är Supabase-formad, inte generisk. Flödesprotokollet accepterar vilken JWKS du än pekar det mot; minnet validerar en token genom att anropa /auth/v1/user på det konfigurerade ursprunget, vilket är Supabases API och ingen OIDC-standard. Om du hostar själv mot någon annan identitetsleverantör fungerar sessionsflödena men inte minnet.

Skillnaden gäller även lokalt utfärdade uppgifter: oharness-sync token <user> räcker för sessionsflöden, men den nuvarande minnesklienten skickar sin Cloud-kontotoken i stället för sync.token eller OHARNESS_SYNC_TOKEN. En egenhostad minnesdriftsättning behöver därför en Cloud-inloggning och en identitetsverifierare som löser upp den token till samma principal.

Lagringen är S3-kompatibel, adresserad som R2. Nycklarna är users/<verified-user-id>/projects/<project-id>/memory/ för projektanteckningar och users/<verified-user-id>/memory/ för en användares egna anteckningar, med användar-id:t hämtat från den verifierade token och inte från något klienten skickar.

Nuvarande begränsning i adaptern. Protokollet och minnes-API:et stöder båda prefixen, men den medföljande createR2MemoryStore har i den här källkodsrevisionen en tillåtelselista för nycklar som fortfarande bara accepterar projektprefixet. Utöka den tillåtelselistan och lägg till ett test mot den riktiga adaptern innan du aktiverar minne i användaromfånget på R2; en oberoende implementation bör stödja båda prefixen som protokollet beskriver.

Saknad konfiguration märks inte. Lagringsklienten byggs vid den första minnesförfrågan i stället för vid uppstart, så en driftsättning som tappat en variabel ser helt frisk ut — porten öppnas, /v1/health svarar, flöden synkroniseras, och minnet returnerar 503 i användarens klient, vilket är det enda ställe där ingen som driftsätter detta tittar. Servern skriver vid uppstart ut en rad som namnger de variabler som saknas:

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

Att inte sätta någon av de sex är det vanliga egenhostade fallet och sägs stillsamt. Att sätta några av dem är fallet som är värt en markering i marginalen. Kontrollera driftsättningsloggen; memory off där är hela varningen.

Kvoterna bestämmer du. Alla är valfria. Svaren anger de fyra gränser som klienten kan agera på; byte-gränserna för förfrågningar och svar är fortsatt säkerhetsgränser på serversidan:

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)

En kvot blockerar tillväxt, aldrig radering, så att sänka en kan inte låsa in en användare som ligger över den. En förfrågan som stoppas av anropsgränsen får 429 med Retry-After, antalet sekunder som återstår av kontots enminutsfönster.

Planer smalnar av kvoten. Gratiskonton får gränserna i packages/sync/src/memory-entitlements.json, lästa från ent-claimet som den officiella Clouds access-token-hook skriver. Om din autentisering inte skriver något sådant claim ser alla konton ut som gratiskonton; sätt detta för att i stället ge alla konton betalgränser:

OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off

Ett värde som inte är ett positivt heltal ignoreras och namnges på uppstartsraden. Driftsätt en ny server före nya klienter: äldre servrar avvisar de fält som nyare klienter skickar för anteckningar på användarnivå och typade anteckningar.

Minnets beteende på klientsidan — lokala anteckningar i första hand, synkbrytaren per katalog, trevägsavstämning och konflikthantering — har en egen sida.

Vad ett projekt inte kan bestämma

sync.url, sync.token och sync.team kastas bort från projektkonfiguration vid sammanfogning, och det är inget förbiseende: att ange synkservern inuti ett repository innebär att ett repository kan skicka dina transkript någon annanstans. De respekteras bara från användarkonfigurationen (~/.oharness/config.json) och miljön.

Den praktiska följden när du hostar själv för ett team: du kan inte leverera serverns adress i en incheckad oharness.config.json och få alla att plocka upp den. Varje person sätter den en gång i sin användarkonfiguration, eller så distribuerar du OHARNESS_SYNC_URL via det som redan konfigurerar deras skal.

Ett projekt kan fortfarande stänga av synkronisering, lägga till i exclude och lägga till i deny — att strama åt är tillåtet, att lätta på är det inte. Logiken finns i projectSafe() i packages/engine/src/config.ts.

Driftanteckningar

Terminera TLS framför den. Servern talar vanlig HTTP och autentiserar med en bearer-token. En token som skickas i klartext över ett nätverk du inte kontrollerar är en token som alla på den vägen nu har. Bind bredare än loopback (--host 0.0.0.0) bara bakom en proxy du själv äger.

En tenant-gräns här är ett nyckelprefix, inte en container. Multi-tenancy är en process som isolerar användare per flödeskatalog. Det är sunt för ett team som redan litar på varandra och är inte modellen att välja om dina tenants inte gör det.

Omstarter är billiga. Servern håller inget i minnet som en klient inte kan härleda igen från sin cursor, och en tappad long poll görs om. Starta om fritt; tappa bara inte --root.

Den läser ingen konfigurationsfil. Det som driftsätter den — en containers miljö, en systemd-enhet, en plattforms variabellager — är den enda källan till konfiguration, eftersom en synkserver som läser en fil från arbetskatalogen är en synkserver vars destination ett repository kan ändra.

railway.sync.toml i repositoryts rot är ett genomarbetat exempel på allt ovan som en enda driftsättning.