Własny serwer synchronizacji

Synchronizacja to opcjonalna połowa OHarness: przechowuje rozmowy, aby sesję rozpoczętą na jednej maszynie można było wznowić na innej. Klient rezerwuje https://sync.oharness.ai jako oficjalny adres domyślny, ale ta usługa to wygoda, a nie zależność — serwer to pakiet npm @oharness/sync, opublikowany na licencji MIT, a skierowanie harnessu na własną kopię to dwie linie konfiguracji.

Nic z tego nie jest wymagane. Niezalogowany harness bez tokenu do własnego serwera nigdy nie otwiera połączenia synchronizacji, a lokalny magazyn sesji jest kompletny sam w sobie. Zalogowanie domyślnie włącza synchronizację i używa oficjalnego adresu, chyba że użytkownik go nadpisze lub wyłączy synchronizację.

Czym serwer jest, a czym nie jest

Przechowuje nieprzezroczyste bloby i oddaje je w kolejności. Nigdy nie uruchamia modelu, nigdy niczego nie wykonuje i nigdy nie zagląda do tego, co przechowuje — serwer nie dotyka body w komunikacji (packages/engine/src/sync/protocol.ts).

To właściwość, której należy pilnować, a nie opis obecnej implementacji. To ona sprawia, że serwer można bezpiecznie zostawić uruchomiony, że format wiadomości może się zmieniać bez ponownego wdrożenia i że tryb szyfrowania end-to-end byłby zmianą wyłącznie po stronie klienta. Funkcja, która wymaga od tej usługi parsowania transkrypcji, to funkcja, której miejsce jest gdzie indziej.

Dwie rzeczy, którymi celowo nie jest:

To nie jest system kont. Nie ma endpointu rejestracji, bo serwer synchronizacji, który może tworzyć konta, to serwer synchronizacji z endpointem rejestracji do obrony. Tokeny są wydawane poza tym kanałem — patrz niżej.

To nie jest serwer agenta. Tamten ma dostęp do powłoki i z założenia obsługuje jednego najemcę. Zobacz railway.agent.toml.

Szybki start

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

Następnie w ~/.oharness/config.json:

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

Gdy konfiguracja użytkownika nie ma sync.url, OHARNESS_SYNC_URL zmienia adres docelowy bez edycji pliku. Gdy obecne są oba, celowo wygrywa konfiguracja użytkownika. OHARNESS_SYNC=0 wyłącza synchronizację dla jednego uruchomienia albo dla całego CI. Po zmianie adresu docelowego zrestartuj OHarness.

URL jest walidowany po stronie klienta: wymagany jest HTTPS, a zwykły HTTP jest dozwolony tylko przy pracy deweloperskiej na loopbacku. Osadzone poświadczenia, query lub fragment są odrzucane, a nie usuwane.

Protokół

Synchronizacja sesji ma cztery endpointy. Memory dodaje trzy opcjonalne. Wszystko poza /v1/health wymaga Authorization: Bearer.

TrasaCel
GET /v1/healthSprawdzenie, czy serwer żyje. Celowo bez uwierzytelniania — health check wymagający poświadczenia to health check, którego nikt nie podepnie
GET /v1/projectsIdentyfikatory projektów, do których cokolwiek wysłano, w ramach jednego właściciela
POST /v1/pushDopisuje rekordy i metadane sesji; zwraca nowy kursor i liczbę nowych rekordów
GET /v1/pullCzyta do przodu od kursora; wait=<seconds> włącza long polling
POST /v1/memory/identityOpcjonalne sprawdzenie tożsamości i limitów Memory
POST /v1/memory/snapshotOpcjonalny manifest Memory i treści zmienionych notatek
POST /v1/memory/commitOpcjonalna aktualizacja Memory typu compare-and-swap

Strumień jest tylko do dopisywania, a kursor to nieprzezroczysta, monotoniczna liczba całkowita wydawana przez serwer. Dołączony magazyn JSONL używa numeru zdarzenia i osobno utrzymuje punkty kontrolne z przesunięciami bajtowymi; inna implementacja może użyć sekwencji w bazie danych. Wysyłki są deduplikowane po (session, id), więc ponowienie po utraconej odpowiedzi nic nie kosztuje, zamiast dublować turę. Sesje są rozstrzygane według zasady last-writer-wins po generacji, a usunięcie podróżuje jako tombstone — bez niego drugie urządzenie przywróciłoby to, co usunąłeś.

Pełny kontrakt interoperacyjności, łącznie z kształtami żądań i odpowiedzi JSON, semantyką kursorów, błędami, hashowaniem Memory i listą kontrolną implementacji, opisuje Protokół synchronizacji. Przeczytaj tę stronę, jeśli budujesz zgodną usługę, zamiast wdrażać @oharness/sync.

Limity są w packages/engine/src/sync/protocol.ts i są wspólne dla obu stron, bo klient i serwer, z których każdy ma własne wyobrażenie o protokole, rozjeżdżają się, a rozjazd objawia się rekordami, które po cichu nie dają się scalić:

MAX_PUSH_RECORDS500 wpisów na wysyłkę
MAX_BODY_BYTES8 MiB na żądanie
DEFAULT_PULL_LIMIT500 wpisów na pobranie
MAX_WAIT_SECONDS30 s, poniżej każdego rozsądnego limitu bezczynności proxy

Zbyt duża wysyłka jest odrzucana z 413, a nie przycinana: przyjęcie połowy partii i zgłoszenie sukcesu zgubiłoby tury, które klient uważa potem za dostarczone.

Strumienie są zapisywane do <root>/feed/<kind>-<owner>/<project>.jsonl, gdzie --root domyślnie wskazuje ~/.oharness/sync. Na platformie, która daje kontenerom tymczasowy system plików, umieść to na zamontowanym wolumenie. Jego utrata nie tylko czyści cache — każdy klient zostaje z kursorem do strumienia, który już nie istnieje.

Dwa sposoby posiadania tokenu

Wydany lokalnie. oharness-sync token <user> zapisuje obok strumieni plik users.json z identyfikatorem użytkownika i SHA-256 tokenu; sam token nigdy nie jest przechowywany i jest wypisywany raz. Jeden wpis to przypadek jednego użytkownika i nic nie kosztuje. Nie wymaga żadnej innej usługi i to tryb, którego należy używać, chyba że masz już system kont.

Podpisany przez coś innego. Ustaw OHARNESS_SYNC_JWT_SECRET (HS256) lub OHARNESS_SYNC_JWKS_URL (asymetryczny), a serwer będzie też akceptował tokeny podpisane przez twój system uwierzytelniania, weryfikowane podpisem, a nie pytaniem tego systemu — więc nic na ścieżce żądania nie zależy od działania innej usługi. sub staje się identyfikatorem właściciela. OHARNESS_SYNC_JWT_ISSUER i OHARNESS_SYNC_JWT_AUDIENCE są egzekwowane, gdy są ustawione.

Obie formy rozróżnia się po kształcie: token wydany lokalnie to 32 losowe bajty w base64url i nigdy nie może zawierać kropki. Włączenie JWT nie wyłącza users.json; oba działają nadal.

Zespoły

Zespół to współdzielony strumień. Każdy, komu wydano token z tym samym identyfikatorem zespołu, czyta i zapisuje jedną historię na projekt — dzięki temu dwie osoby mogą kontynuować nawzajem swoje sesje.

npx @oharness/sync token bob --team platform

Przy tokenach podpisanych identyfikatory pochodzą z claimu teams, którego nazwę można zmienić przez OHARNESS_SYNC_TEAMS_CLAIM. Członkostwo jest zapisane w tokenie, a nie sprawdzane przy każdym żądaniu — alternatywa stawia dostępność kogoś innego przed każdym zapisem, na endpoincie, który istnieje po to, by transkrypcje były trwałe.

Wskazanie zespołu, którego token nie zawiera, jest odrzucane z 403, a nie po cichu obsługiwane strumieniem samego wywołującego. Taka podmiana wygląda dla użytkownika jak „zespół nic nie ma”, a to jedyna błędna odpowiedź, na podstawie której by zadziałał.

Memory potrzebuje więcej niż strumień

/v1/memory/* obsługuje ten sam proces, ale to nie jest protokół strumieni: manifest aktualizowany przez compare-and-swap plus niezmienne obiekty Markdown w prywatnym buckecie, z trzema akcjami — identity, snapshot i commit.

Ma wymagania, których protokół strumieni nie ma, i warto je znać, zanim zaczniesz cokolwiek planować:

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

Uwierzytelnianie jest skrojone pod Supabase, a nie ogólne. Protokół strumieni akceptuje dowolny wskazany JWKS; Memory waliduje token, wywołując /auth/v1/user na skonfigurowanym originie, co jest API Supabase, a nie standardem OIDC. Jeśli hostujesz samodzielnie z innym dostawcą tożsamości, strumienie sesji będą działać, a Memory nie.

To rozróżnienie dotyczy też poświadczeń wydanych lokalnie: oharness-sync token <user> wystarcza dla strumieni sesji, ale obecny klient Memory wysyła token konta Cloud, a nie sync.token ani OHARNESS_SYNC_TOKEN. Samodzielnie hostowane wdrożenie Memory potrzebuje więc logowania do Cloud i weryfikatora tożsamości, które rozwiążą ten token do tego samego podmiotu.

Magazyn jest zgodny z S3, adresowany jak R2. Klucze to users/<verified-user-id>/projects/<project-id>/memory/ dla notatek projektu i users/<verified-user-id>/memory/ dla notatek samego użytkownika, przy czym identyfikator użytkownika pochodzi ze zweryfikowanego tokenu, a nie z czegokolwiek, co wysyła klient.

Obecne ograniczenie adaptera. Protokół i API Memory obsługują oba prefiksy, ale lista dozwolonych kluczy w dołączonym createR2MemoryStore w tej wersji źródeł nadal akceptuje tylko prefiks projektu. Rozszerz tę listę i dodaj test na prawdziwym adapterze, zanim włączysz Memory w zakresie użytkownika na R2; niezależna implementacja powinna obsługiwać oba prefiksy opisane w protokole.

Brak konfiguracji jest cichy. Klient magazynu jest tworzony przy pierwszym żądaniu Memory, a nie przy starcie, więc wdrożenie, któremu zgubiła się jedna zmienna, wygląda na w pełni zdrowe — port się otwiera, /v1/health odpowiada, strumienie się synchronizują, a Memory zwraca 503 w kliencie użytkownika, czyli w jedynym miejscu, którego nikt wdrażający nie obserwuje. Serwer przy starcie wypisuje jedną linię z nazwami brakujących zmiennych:

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

Nieustawienie żadnej z sześciu to zwykły przypadek samodzielnego hostowania i serwer mówi o tym cicho. Ustawienie tylko niektórych to przypadek wart notatki na marginesie. Sprawdź log wdrożenia; memory off w nim to całe ostrzeżenie.

Limity ustalasz sam. Każdy jest opcjonalny. Odpowiedzi ogłaszają cztery limity, na które klient może reagować; limity bajtów żądania i odpowiedzi pozostają zabezpieczeniami po stronie serwera:

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)

Limit blokuje wzrost, nigdy usuwanie, więc jego obniżenie nie może uwięzić użytkownika powyżej niego. Żądanie objęte limitem zapytań dostaje 429 z Retry-After, czyli liczbą sekund pozostałych w jednominutowym oknie konta.

Plany zawężają limity. Darmowe konta dostają limity z packages/sync/src/memory-entitlements.json, odczytywane z claimu ent, który zapisuje hook tokenu dostępu oficjalnego Cloud. Jeśli twoje Auth nie zapisuje takiego claimu, każde konto wygląda na darmowe; ustaw to, aby zamiast tego dać każdemu kontu limity płatne:

OHARNESS_SYNC_MEMORY_ENTITLEMENTS=off

Wartość, która nie jest dodatnią liczbą całkowitą, jest ignorowana i wymieniana w linii startowej. Wdróż nowy serwer przed nowymi klientami: starsze serwery odrzucają pola, które nowsi klienci wysyłają dla notatek na poziomie użytkownika i notatek z typem.

Zachowanie Memory po stronie klienta — notatki local-first, przełącznik synchronizacji dla każdego katalogu, trójstronne uzgadnianie i obsługa konfliktów — ma osobną stronę.

Czego projekt nie może zdecydować

sync.url, sync.token i sync.team są odrzucane z konfiguracji projektu przy scalaniu i nie jest to przeoczenie: wskazanie serwera synchronizacji w repozytorium oznacza, że repozytorium może wysłać twoje transkrypcje gdzie indziej. Są honorowane tylko z konfiguracji użytkownika (~/.oharness/config.json) i ze środowiska.

Praktyczna konsekwencja przy hostowaniu dla zespołu: nie możesz umieścić adresu swojego serwera w zacommitowanym oharness.config.json i oczekiwać, że wszyscy go przejmą. Każda osoba ustawia go raz w swojej konfiguracji użytkownika albo rozprowadzasz OHARNESS_SYNC_URL przez to, co już konfiguruje ich powłokę.

Projekt nadal może wyłączyć synchronizację, dopisać do exclude i dopisać do deny — zaostrzanie jest dozwolone, luzowanie nie. Logika to projectSafe() w packages/engine/src/config.ts.

Uwagi operacyjne

Terminuj TLS przed serwerem. Serwer mówi zwykłym HTTP i uwierzytelnia tokenem bearer. Token wysłany otwartym tekstem w sieci, której nie kontrolujesz, to token, który ma teraz każdy na tej ścieżce. Nasłuchuj szerzej niż na loopbacku (--host 0.0.0.0) tylko za proxy, które jest twoje.

Granica najemcy to tu prefiks klucza, a nie kontener. Wielodostępność to jeden proces izolujący użytkowników katalogiem strumienia. To rozsądne dla zespołu, którego członkowie już sobie ufają, ale nie jest to model dla najemców, którzy sobie nie ufają.

Restarty są tanie. Serwer nie trzyma w pamięci niczego, czego klient nie odtworzy ze swojego kursora, a przerwany long poll jest ponawiany. Restartuj śmiało; po prostu nie zgub --root.

Nie czyta żadnego pliku konfiguracyjnego. To, co go wdraża — środowisko kontenera, jednostka systemd, magazyn zmiennych platformy — jest jedynym źródłem konfiguracji, bo serwer synchronizacji czytający plik z katalogu roboczego to serwer synchronizacji, którego adres docelowy może zmienić repozytorium.

railway.sync.toml w katalogu głównym repozytorium to gotowy przykład wszystkiego powyższego jako jednego wdrożenia.