Synkprotokoll
Den här sidan är kontraktet för interoperabilitet för alla som implementerar en synktjänst för OHarness. Den beskriver vad klienten skickar och vilket beteende den förlitar sig på; den kräver inte JSONL-lagringen som @oharness/sync använder.
Det finns två oberoende protokoll under ett ursprung:
- Sessionssynkronisering speglar transkript, artefaktrevisioner och sessionsmetadata genom ett append-only-flöde.
- Minnessynkronisering stämmer av Markdown-anteckningar genom ett manifest och compare-and-swap. Den är valfri. En server kan implementera sessionssynkronisering utan att implementera minnet.
Bas-URL:en innehåller ingen sökväg, query, inloggningsuppgifter eller fragment. Klienter i produktion accepterar bara HTTPS; vanlig HTTP accepteras för utveckling mot localhost, 127.0.0.1 och [::1]. Exemplen nedan använder https://sync.example.com.
På den här sidan
- [Kompatibilitetsmål](#compatibility-target)
- [Autentisering och tenancy](#authentication-and-tenancy)
- [Datamodell för sessionssynkronisering](#session-sync-data-model)
- [Sessions-endpoints](#session-endpoints)
- [Felsvar för sessioner](#session-error-responses)
- [Minnessynkronisering](#memory-sync)
- [Konfigurera en OHarness-klient](#configuring-an-oharness-client)
- [Checklista för implementation](#implementation-checklist)
- [Det som inte krävs](#non-requirements)
Kompatibilitetsmål
Det här dokumentet beskriver protokollversion 1, som den används av OHarness 0.5.x. Det finns för närvarande ingen endpoint för förmågor och ingen innehållsförhandling. Håll /v1/ bakåtkompatibelt: lägg till valfria fält, ändra inte betydelsen av ett befintligt fält och gör inte ett tidigare valfritt fält obligatoriskt.
Okända fält i förfrågningar bör ignoreras för sessionssynkronisering. Minnet är striktare och avvisar dem med 400 {"code":"unknown_field"}; det svaret används av nya klienter för att upptäcka en äldre minnesserver och göra om en projektsnapshot utan optimeringen have.
Alla tidsstämplar är Unix-millisekunder. Alla meddelandekroppar är JSON. Lyckade svar använder Content-Type: application/json; klienter förlitar sig inte på något särskilt JSON-blanksteg eller någon särskild ordning på egenskaperna.
Autentisering och tenancy
Varje route utom GET /v1/health kräver:
Authorization: Bearer <token>
En giltig token löses upp till exakt ett stabilt principal-id. Servern måste härleda ägaren från den verifierade token, aldrig från ett användar-id som anges i en förfrågan. En sessionstoken kan dessutom innehålla team-id:n.
Utan team adresserar sessionsförfrågningar principalens personliga flöde. Med ett team adresserar de det teamets flöde, först efter att medlemskapet kontrollerats. Ett okänt eller obehörigt team returnerar 403; det får inte i tysthet falla tillbaka på det personliga flödet.
Autentiseringen är medvetet inte standardiserad utöver Bearer-transporten. Den medföljande servern accepterar lokalt utfärdade opaka tokens, HS256-JWT:er och JWT:er som verifieras via JWKS. En annan implementation kan använda en databas eller en annan tokenverifierare så länge den observerbara semantiken för principal och team är densamma.
Minnet har en starkare identitetskontroll som beskrivs nedan. I synnerhet räcker en opak token som utfärdats av oharness-sync token för sessionssynkronisering, men den nuvarande minnesklienten i OHarness använder sin Cloud-kontotoken.
Datamodell för sessionssynkronisering
Post
En post är ett oföränderligt objekt i en session. I dag innehåller den normalt en transkriptpost eller ett kuvert för en artefaktrevision, men servern måste behandla body som opak JSON.
{
"session": "20260914_abcd",
"id": "stable-record-id",
"seq": 12,
"device": "device-id",
"at": 1789344000000,
"body": { "opaque": "to the server" }
}
| Fält | Typ | Betydelse |
|---|---|---|
session | string | Sessionen som innehåller posten |
id | string | Stabilt id som används för deduplicering vid nya försök |
seq | number | Lamport-sekvens inom sessionen |
device | string | Enheten som skapade posten |
at | number | Skapandetid i Unix-millisekunder |
body | valfri JSON | Nyttolast som lagras och returneras oförändrad |
Dedupliceringsnyckeln är (session, id). Att pusha den nyckeln igen får inte lägga till en andra händelse, även om ett tidigare svar gick förlorat. Tolka, normalisera eller skriv inte om body.
Sessionsmetadata
{
"id": "20260914_abcd",
"gen": 1,
"updatedAt": 1789344000000,
"body": { "opaque": "session metadata" }
}
body är opak. deleted: true är en tombstone och har normalt ingen body. Metadata avgörs med last-writer-wins utifrån den här tupeln, i fallande prioritet:
- Högre
genvinner. En komprimering eller omskrivning ökar generationen. - Inom en generation vinner högre
updatedAt. - En lika tupel finns redan och lägger inte till någon händelse.
Den vinnande uppdateringen, även en tombstone, läggs till i flödet så att enheter som redan läst ett äldre värde kan se ändringen.
Cursor
En cursor är ett icke-negativt heltal som servern utfärdar. Den identifierar en position i ett (owner, project)-flöde. Klienten behandlar den som opak: den sparar den, skickar tillbaka den som since och förväntar sig bara att den aldrig flyttas bakåt.
Den medföljande JSONL-implementationen använder ett ökande händelsenummer. En databasimplementation kan använda en sekvens. En cursor behöver inte vara en byte-offset, en tidsstämpel eller postens seq.
Cursorer hör till ett flöde och får inte återanvändas mellan användare, team eller projekt. Lagringen av flöden är beständigt tillstånd. Om en server tappar ett flöde medan en klient behåller sin cursor kan klienten för alltid ligga före det nya flödet; protokoll v1 har ingen handskakning för att återställa cursorn.
Sessions-endpoints
GET /v1/health
Oautentiserad liveness-endpoint.
HTTP/1.1 200 OK
Content-Type: application/json
{"ok":true}
Det här bevisar att processen och routen går att nå. Det bevisar inte att en token fungerar, att beständig lagring är monterad eller att minnet är konfigurerat.
GET /v1/projects
Listar projekt som har ett flöde för den valda ägaren.
GET /v1/projects
GET /v1/projects?team=platform
Svar:
{"projects":["project-a","project-b"]}
Returnera en tom array när ägaren inte har några flöden. Id:n som nuvarande OHarness-klienter skapar matchar [A-Za-z0-9_-]{1,128} och bör returneras oförändrade. En server kan avvisa eller reversibelt koda annan indata; foga aldrig in ett opålitligt id i en sökväg i filsystemet.
Den nuvarande spegeln per projekt anropar inte den här routen i sin vanliga push/pull-loop, men den är en del av den publika klienten och krävs för en fullständig implementation och för upptäckt av projekt mellan enheter.
POST /v1/push
Lägger atomiskt till nya poster och vinnande sessionsmetadata.
{
"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 och sessions är valfria. Saknade arrayer betyder tomma arrayer. Åtminstone project krävs. Äldre typdeklarationer innehöll en device på toppnivå; OHarness-klienten skickar den inte och kompatibla servrar får inte kräva den. Enhetsidentiteten finns på varje post.
Svar:
{"cursor":42,"accepted":2}
cursor är flödets aktuella slut efter den här pushen. accepted räknar nya posthändelser plus vinnande händelser för sessionsmetadata. Dubblerade poster och inaktuell eller lika sessionsmetadata räknas inte.
Hela förfrågan går att göra om. Validera den innan någon post skrivs. Acceptera inte en partiell batch: returnera ett fel för hela förfrågan om någon post är felformad eller en gräns överskrids.
Gränser som den nuvarande klienten och den medföljande servern använder:
| Gräns | Värde |
|---|---|
| Poster i en push | 500 |
| Poster med sessionsmetadata i en push | 500 |
| Serialiserad förfrågningskropp | 8 MiB |
De två arraygränserna är oberoende: en förfrågan kan innehålla 500 poster och 500 sessionsposter.
GET /v1/pull
Läser flödeshändelser efter en cursor.
GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
| Query | Krävs | Betydelse |
|---|---|---|
project | ja | Opakt projekt-id |
since | ja vid normal användning | Returnera händelser strikt efter den här cursorn; 0 börjar från början |
limit | nej | Sidstorlek, begränsad till 1..500; standard 500 |
wait | nej | Sekunder för long polling, begränsat till 0..30; standard 0 |
team | nej | Teamets flöde i stället för principalens flöde |
Svar:
{
"cursor": 22,
"records": [],
"sessions": [],
"more": false
}
Returnera händelser i flödesordning och utan fält som bara finns på servern. Cursorn är den senast returnerade händelsen. Om det inte finns några nya händelser förblir den lika med since. Sätt more: true bara när ännu en sida redan finns tillgänglig; klienten gör då en ny pull direkt.
När wait > 0 och flödet är ikapp, håll kvar förfrågan tills en händelse läggs till eller väntetiden går ut. En timeout är ett lyckat tomt svar, inte ett HTTP-fel. Att koppla från en long poll får inte ändra flödets tillstånd.
Felsvar för sessioner
Sessionsfel är JSON-objekt med en kort, läsbar error-sträng:
{"error":"missing or bad token"}
Använd konventionella statuskoder:
| Status | När |
|---|---|
400 | Ogiltig JSON, saknat projekt, ogiltig cursor eller felformad post |
401 | Saknad, utgången eller ogiltig Bearer-token; inkludera WWW-Authenticate: Bearer |
403 | Den autentiserade principalen är inte medlem i det begärda teamet |
413 | Antalet bytes eller poster i förfrågan överskrider gränsen |
404 | Okänd route |
500 | Oväntat lagringsfel; påstå inte att en partiell push lyckades |
Spegeln gör om nätverksfel och vanliga serverfel med backoff. En 401 stoppar spegeln tills den startas om med en användbar inloggningsuppgift.
Minnessynkronisering
Minnessynkronisering är valfri och separat från append-only-flödet för sessioner. Alla tre routerna är autentiserade POST-förfrågningar med Content-Type: application/json:
/v1/memory/identity/v1/memory/snapshot/v1/memory/commit
Den nuvarande klienten är enbart inbyggd. En förfrågan med en Origin-header avvisas med 403 {"code":"native_client_required"}.
Minnesidentitet
Varje förfrågan innehåller expectedPrincipal, hämtad från det lokalt avkodade Cloud-kontot som en samtyckesnål. Servern måste självständigt validera Bearer-token och kräva att den verifierade principalen matchar den. Auktorisera aldrig ett objektprefix enbart utifrån expectedPrincipal.
Förfrågan:
{"expectedPrincipal":"user-id"}
Svar:
{
"principal": "user-id",
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
Den medföljande implementationen validerar tokens via Supabase GET /auth/v1/user, men en kompatibel implementation kan använda en annan kontotjänst. Den måste avvisa anonyma, raderade, utgångna eller på annat sätt ogiltiga konton och returnera samma stabila principal-id som klienten förväntar sig.
Omfång och anteckningsformat
Projektomfång uttrycks med project utan egenskapen scope:
{"project":"project-a"}
Användaromfång har scope: "user" och får inte innehålla project:
{"scope":"user"}
Protokollet kräver båda omfången. Den nuvarande medföljande R2-adaptern har en känd lucka i tillåtelselistan för nycklar för användarprefixet; det ändrar inte kontraktet på tråden för oberoende implementationer.
En anteckning är:
{
"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 är valfri och är, när den finns, en av user, feedback, project eller reference. updatedAt är valfri metadata och utesluts medvetet ur innehållshashen.
Valideringsgränserna är en del av kontraktet på tråden:
| Fält | Begränsning |
|---|---|
| Principal-, projekt- och antecknings-id:n | [A-Za-z0-9_-]{1,128}; avvisa även __proto__, constructor och prototype |
summary | Trimmad, en rad, 1–500 tecken |
body | Trimmad sträng, högst den angivna maxNoteBytes i UTF-8 |
tags | Högst 30; var och en 1–64 tecken utan blanksteg, kommatecken eller radbrytning |
createdAt | Icke-negativt säkert heltal |
updatedAt | Valfritt icke-negativt säkert heltal |
Id:n för levande anteckningar är unika oavsett skiftläge. Det krävs eftersom en server inte får skapa ett tillstånd som vanliga skiftlägesokänsliga filsystem på klienter inte kan representera.
Anteckningens hash, SHA-256 i hexadecimal med gemener, beräknas över UTF-8-JSON med exakt den här positionella arrayen:
JSON.stringify([id, summary, body, tags, createdAt])
När och endast när type finns läggs den till:
JSON.stringify([id, summary, body, tags, createdAt, type])
Implementationer måste återskapa JavaScripts JSON.stringify för de här värdena. Undantaget för kompatibilitet bevarar hashar som skapades innan anteckningstyper fanns.
POST /v1/memory/snapshot
Förfrågan för projekt:
{
"expectedPrincipal": "user-id",
"project": "project-a",
"knownEtag": "optional-previous-etag",
"have": {
"borderless-cards-a91": "64-lowercase-hex-characters"
}
}
För användaromfång ersätter du project med "scope":"user".
knownEtag möjliggör en snabbväg när inget har ändrats. have mappar antecknings-id:n till hashar som klienten redan har; servern kan utelämna de anteckningarnas innehåll utan att läsa dem från objektlagringen. Avvisa en have-mappning med fler än maxNotes * 2 + 1000 nycklar.
Svar när inget har ändrats:
{
"principal": "user-id",
"etag": "same-etag",
"unchanged": true,
"quota": {
"maxNotes": 5000,
"maxChanges": 500,
"maxNoteBytes": 131072,
"tombstoneDays": 90
}
}
Fullständigt eller inkrementellt svar:
{
"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
}
}
En levande post får utelämna note bara när have[id] är lika med dess hash. En tombstone har hash: null och ingen anteckning. etag är null när inget manifest finns.
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": {}
}
För användaromfång ersätter du project med "scope":"user". Ett skapande eller en uppdatering måste innehålla en anteckning vars id och beräknade hash matchar ändringen. En radering har hash: null och ingen note. changes måste innehålla mellan 1 och det angivna maxChanges poster, utan dubblerade id:n.
Tillämpa committen bara när etag fortfarande identifierar det aktuella manifestet. Vid en inaktuell ETag returnerar du 409 {"code":"revision_conflict"} och lämnar det publicerade manifestet oförändrat. Skriv nya oföränderliga anteckningsrevisioner innan det nya manifestet publiceras, och publicera manifestet atomiskt. En samtidig skrivare som förlorar kan lämna kvar onåbara oföränderliga objekt för senare skräpsamling, men inget partiellt tillstånd får bli synligt via manifestet. Svaret har samma form som en lyckad snapshot, med den nya ETag:en och de aktuella posterna.
Kasta inte raderingar direkt. Behåll tombstones under det angivna fönstret så att offlineklienter kan se dem. Att sänka en kvot får inte låsa in en principal som ligger över den: avvisa tillväxt över maxNotes, men fortsätt att tillåta raderingar.
Minnesfel och gränser
Minnesfel använder en stabil, maskinläsbar code:
{"code":"revision_conflict"}
Koder som den nuvarande implementationen använder är bland annat:
| Status | Koder |
|---|---|
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 |
De medföljande standardvärdena är 5 000 levande anteckningar per omfång, 500 ändringar per commit, 128 KiB per anteckningsinnehåll, 4 MiB per förfrågan, 16 MiB serialiserad postdata per svar och 90 dagar för tombstones. Bara de fyra värden som klienten kan agera på och som visas i quota anges på tråden. En klient tillämpar också egna hårda säkerhetstak, så att höja serverns kvoter garanterar inte att en klient accepterar ett godtyckligt stort svar.
Konfigurera en OHarness-klient
Lägg destinationen i användarens konfiguration, aldrig i ett repositorys konfiguration:
{
"sync": {
"url": "https://sync.example.com",
"token": "opaque-session-token",
"team": "optional-team",
"enabled": true
}
}
Filen är $OHARNESS_HOME/config.json när OHARNESS_HOME är satt, $XDG_CONFIG_HOME/oharness/config.json på Linux när XDG är satt, och ~/.oharness/config.json annars. Starta om OHarness efter att du bytt destination.
Den nuvarande prioritetsordningen är:
sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token
Följaktligen ersätter OHARNESS_SYNC_URL bara den hårdkodade standarden när användarkonfigurationen saknar sync.url. OHARNESS_SYNC=0 stänger av sessionssynkronisering oavsett destination eller inloggningsuppgift.
Ett repositorys oharness.config.json- och .oharness.json-filer kan inte sätta sync.url, sync.token eller sync.team; OHarness kastar de värdena. Ett repository kan bara stänga av synkronisering eller lägga till undantag. Det hindrar ett klonat repository från att omdirigera en användares transkript.
Samma upplösta bas-URL används för minnet, men den nuvarande minnesklienten använder Cloud-kontots inloggningsuppgift, inte sync.token eller OHARNESS_SYNC_TOKEN. För att hosta minnet själv måste klientinloggningen och minnesserverns identitetsverifierare därför vara överens om samma principal.
Checklista för implementation
En kompatibel tjänst bör klara alla dessa beteenden:
- Hälsokontrollen fungerar utan token; varje dataroute misslyckas stängt utan en.
- Två principaler som använder samma projekt-id kan inte se varandras data.
- Personliga flöden och teamflöden är åtskilda; obehöriga team returnerar
403. - Att göra om en push dubblerar inte poster eller inaktuell sessionsmetadata.
- Pull är ordnad, paginerad, har monoton cursor och är säker att long-polla.
- Sessionsinnehåll överlever en push/pull oförändrat på JSON-värdenivå.
- Sessionsraderingar och minnesraderingar når en andra enhet som är offline.
- En inaktuell ETag för minnet publicerar ingen ändring i manifestet.
- Objektnycklar för minnet använder alltid den serververifierade principalen.
- Hashkonflikter, kollisioner mellan antecknings-id:n som bara skiljer sig i skiftläge och för stora förfrågningar avvisas innan ett manifest publiceras.
- Beständigt tillstånd för flöden och manifest överlever omstarter av processen och driftsättningar.
Repositoryts sessionstester i test/sync.test.ts och minnestester i script/sync-memory.test.ts är körbara exempel på kontraktet. De instansierar för närvarande den medföljande servern direkt i stället för att ta emot en fjärr-URL, så en annan implementation bör porta samma fall eller tillhandahålla en liten adapter innan den påstår sig vara kompatibel.
För ett första rökprov mot en fjärrserver utfärdar du en engångstoken och sätter in dess värde nedan. Använd inte en kontotoken från produktion i en skalhistorik:
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"
Pusha samma body en gång till och kontrollera att accepted är 0; gör sedan en pull från den returnerade cursorn och kontrollera att arrayerna är tomma och att cursorn är oförändrad. De här kontrollerna fastställer nåbarhet och grundläggande idempotens, inte isolering mellan tenants eller säkerhet för samtidig CAS.
Det som inte krävs
En kompatibel server behöver inte:
- köra en modell, köra verktyg eller förstå meddelandeformaten i transkript;
- använda JSONL, R2, Supabase eller någon särskild databas;
- tillhandahålla registrering, inloggning, utfärdande av tokens eller en webbläsarapp;
- implementera minnet för att implementera sessionssynkronisering;
- exponera CORS. De nuvarande klienterna är inbyggda Node/Electron-klienter.
Att hålla dessa frågor utanför protokollet är det som låter en liten egenhostad tjänst och en driftsättning med flera tenants tala samma protokoll på tråden.