Protocollo di sincronizzazione

Questa pagina è il contratto di interoperabilità per chiunque implementi un servizio di sincronizzazione per OHarness. Descrive cosa invia il client e il comportamento su cui fa affidamento; non richiede l'archiviazione JSONL usata da @oharness/sync.

Sotto un'unica origine ci sono due protocolli indipendenti:

  • La sincronizzazione delle sessioni replica trascrizioni, revisioni degli artefatti e metadati di sessione tramite un feed append-only.
  • La sincronizzazione della memoria riconcilia note Markdown tramite un manifest e compare-and-swap. È opzionale. Un server può implementare la sincronizzazione delle sessioni senza implementare la memoria.

L'URL di base non contiene percorso, query, credenziali né frammento. I client di produzione accettano solo HTTPS; HTTP semplice è accettato per lo sviluppo su localhost, 127.0.0.1 e [::1]. Gli esempi seguenti usano https://sync.example.com.

In questa pagina

  • [Obiettivo di compatibilità](#compatibility-target)
  • [Autenticazione e tenancy](#authentication-and-tenancy)
  • [Modello dei dati della sincronizzazione delle sessioni](#session-sync-data-model)
  • [Endpoint delle sessioni](#session-endpoints)
  • [Risposte di errore delle sessioni](#session-error-responses)
  • [Sincronizzazione della memoria](#memory-sync)
  • [Configurare un client OHarness](#configuring-an-oharness-client)
  • [Checklist di implementazione](#implementation-checklist)
  • [Cosa non è richiesto](#non-requirements)

Obiettivo di compatibilità

Questo documento descrive la versione 1 del protocollo, usata da OHarness 0.5.x. Al momento non esiste un endpoint delle funzionalità né una negoziazione dei contenuti. Mantieni /v1/ retrocompatibile: aggiungi campi opzionali, non cambiare il significato di un campo esistente e non rendere obbligatorio un campo prima opzionale.

Nella sincronizzazione delle sessioni i campi sconosciuti delle richieste vanno ignorati. La memoria è più rigida e li rifiuta con 400 {"code":"unknown_field"}; i nuovi client usano questa risposta per riconoscere un server della memoria più vecchio e ripetere lo snapshot di un progetto senza l'ottimizzazione have.

Tutti i timestamp sono in millisecondi Unix. Tutti i corpi sono JSON. Le risposte riuscite usano Content-Type: application/json; i client non dipendono da particolari spazi nel JSON né dall'ordine delle proprietà.

Autenticazione e tenancy

Ogni route tranne GET /v1/health richiede:

Authorization: Bearer <token>

Un token valido si risolve in esattamente un id di principal stabile. Il server deve ricavare il proprietario dal token verificato, mai da un id utente fornito in una richiesta. Un token di sessione può contenere anche id di team.

Senza team, le richieste di sessione si rivolgono al feed personale del principal. Con un team, si rivolgono al feed di quel team solo dopo aver verificato l'appartenenza. Un team sconosciuto o non autorizzato restituisce 403; non deve ripiegare in silenzio sul feed personale.

L'autenticazione volutamente non è standardizzata oltre il trasporto Bearer. Il server incluso accetta token opachi emessi localmente, JWT HS256 e JWT verificati tramite JWKS. Un'altra implementazione può usare un database o un verificatore di token diverso, purché la semantica osservabile di principal e team sia la stessa.

La memoria ha un controllo di identità più forte, descritto sotto. In particolare, un token opaco emesso da oharness-sync token basta per la sincronizzazione delle sessioni, ma l'attuale client della memoria di OHarness usa il token del suo account Cloud.

Modello dei dati della sincronizzazione delle sessioni

Record

Un record è un elemento immutabile di una sessione. Oggi di solito contiene un record di trascrizione o l'envelope di una revisione di artefatto, ma il server deve trattare body come JSON opaco.

{
  "session": "20260914_abcd",
  "id": "stable-record-id",
  "seq": 12,
  "device": "device-id",
  "at": 1789344000000,
  "body": { "opaque": "to the server" }
}
CampoTipoSignificato
sessionstringSessione che contiene il record
idstringId stabile usato per la deduplicazione dei nuovi tentativi
seqnumberSequenza di Lamport all'interno della sessione
devicestringDispositivo che ha creato il record
atnumberMomento di creazione in millisecondi Unix
bodyqualsiasi JSONPayload archiviato e restituito senza modifiche

La chiave di deduplicazione è (session, id). Inviare di nuovo quella chiave non deve aggiungere un secondo evento, anche se una risposta precedente è andata persa. Non analizzare, normalizzare né riscrivere body.

Metadati di sessione

{
  "id": "20260914_abcd",
  "gen": 1,
  "updatedAt": 1789344000000,
  "body": { "opaque": "session metadata" }
}

body è opaco. deleted: true è un tombstone e di solito non ha corpo. I metadati seguono last-writer-wins con questa tupla, in ordine di priorità decrescente:

  1. Vince il gen maggiore. Una compattazione o una riscrittura incrementa la generazione.
  2. All'interno di una generazione, vince l'updatedAt maggiore.
  3. Una tupla uguale è già posseduta e non aggiunge alcun evento.

L'aggiornamento vincente, tombstone compreso, viene aggiunto al feed, così i dispositivi che hanno già letto un valore più vecchio possono osservare la modifica.

Cursore

Un cursore è un intero non negativo emesso dal server. Identifica una posizione in un feed (owner, project). Il client lo tratta come opaco: lo archivia, lo rimanda come since e si aspetta solo che non torni mai indietro.

L'implementazione JSONL inclusa usa un numero di evento incrementale. Un'implementazione su database può usare una sequenza. Non è richiesto che un cursore sia un offset di byte, un timestamp o il seq di un record.

I cursori appartengono a un solo feed e non devono essere riutilizzati tra utenti, team o progetti. L'archiviazione dei feed è uno stato durevole. Se un server perde un feed mentre un client ne conserva il cursore, il client può restare permanentemente avanti rispetto al nuovo feed; il protocollo v1 non ha un handshake di reimpostazione del cursore.

Endpoint delle sessioni

GET /v1/health

Endpoint di liveness non autenticato.

HTTP/1.1 200 OK
Content-Type: application/json

{"ok":true}

Dimostra che il processo e la route sono raggiungibili. Non dimostra che un token funzioni, che l'archiviazione persistente sia montata né che la memoria sia configurata.

GET /v1/projects

Elenca i progetti che hanno un feed per il proprietario selezionato.

GET /v1/projects
GET /v1/projects?team=platform

Risposta:

{"projects":["project-a","project-b"]}

Restituisci un array vuoto quando il proprietario non ha feed. Gli ID prodotti dagli attuali client OHarness corrispondono a [A-Za-z0-9_-]{1,128} e vanno restituiti senza modifiche. Un server può rifiutare o codificare in modo reversibile altri input; non concatenare mai un id non attendibile in un percorso del filesystem.

L'attuale mirror per progetto non chiama questa route durante il normale ciclo di push/pull, ma fa parte del client pubblico ed è richiesta per un'implementazione completa e per la scoperta dei progetti tra dispositivi.

POST /v1/push

Aggiunge in modo atomico nuovi record e i metadati di sessione vincenti.

{
  "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 e sessions sono opzionali. Gli array mancanti valgono come array vuoti. È richiesto almeno project. Le dichiarazioni di tipo più vecchie includevano un device di primo livello; il client OHarness non lo invia e i server compatibili non devono richiederlo. L'identità del dispositivo sta in ciascun record.

Risposta:

{"cursor":42,"accepted":2}

cursor è la fine attuale del feed dopo questo invio. accepted conta i nuovi eventi di record più gli eventi di metadati di sessione vincenti. I record duplicati e i metadati di sessione obsoleti o uguali non contano.

L'intera richiesta può essere ritentata. Convalidala prima di salvare qualsiasi voce. Non accettare un lotto parziale: restituisci un errore per l'intera richiesta se una voce è malformata o se un limite viene superato.

Limiti usati dal client attuale e dal server incluso:

LimiteValore
Record in un invio500
Voci di metadati di sessione in un invio500
Corpo della richiesta serializzato8 MiB

I due limiti sugli array sono indipendenti: una richiesta può contenere 500 record e 500 voci di sessione.

GET /v1/pull

Legge gli eventi del feed successivi a un cursore.

GET /v1/pull?project=project-a&since=20&limit=500&wait=30&team=platform
QueryObbligatorioSignificato
projectsìId di progetto opaco
sincesì nell'uso normaleRestituisce gli eventi strettamente successivi a questo cursore; 0 parte dall'inizio
limitnoDimensione della pagina, limitata a 1..500; predefinito 500
waitnoSecondi di long polling, limitati a 0..30; predefinito 0
teamnoFeed del team invece del feed del principal

Risposta:

{
  "cursor": 22,
  "records": [],
  "sessions": [],
  "more": false
}

Restituisci gli eventi nell'ordine del feed e senza campi riservati al server. Il cursore è l'ultimo evento restituito. Se non ci sono nuovi eventi resta uguale a since. Imposta more: true solo quando è già disponibile un'altra pagina; il client allora legge di nuovo immediatamente.

Quando wait > 0 e il feed è aggiornato, trattieni la richiesta finché non viene aggiunto un evento o finché l'attesa non scade. Un timeout è una risposta vuota riuscita, non un errore HTTP. Interrompere un long poll non deve modificare lo stato del feed.

Risposte di errore delle sessioni

Gli errori di sessione sono oggetti JSON con una breve stringa error leggibile:

{"error":"missing or bad token"}

Usa i codici di stato convenzionali:

StatoQuando
400JSON non valido, progetto mancante, cursore non valido o voce malformata
401Bearer token mancante, scaduto o non valido; includi WWW-Authenticate: Bearer
403Il principal autenticato non è membro del team richiesto
413I byte della richiesta o il numero di voci superano il limite
404Route sconosciuta
500Errore di archiviazione imprevisto; non dichiarare riuscito un invio parziale

Il mirror ritenta gli errori di rete e i normali guasti del server con backoff. Un 401 ferma quel mirror finché non viene riavviato con una credenziale utilizzabile.

Sincronizzazione della memoria

La sincronizzazione della memoria è opzionale e separata dal feed append-only delle sessioni. Tutte e tre le route sono richieste POST autenticate con Content-Type: application/json:

  • /v1/memory/identity
  • /v1/memory/snapshot
  • /v1/memory/commit

Il client attuale è solo nativo. Una richiesta con un'intestazione Origin viene rifiutata con 403 {"code":"native_client_required"}.

Identità della memoria

Ogni richiesta contiene expectedPrincipal, preso dall'account Cloud decodificato localmente come vincolo di consenso. Il server deve convalidare in modo indipendente il Bearer token e richiedere che il principal verificato corrisponda. Non autorizzare mai un prefisso di oggetti sulla sola base di expectedPrincipal.

Richiesta:

{"expectedPrincipal":"user-id"}

Risposta:

{
  "principal": "user-id",
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

L'implementazione inclusa convalida i token tramite GET /auth/v1/user di Supabase, ma un'implementazione compatibile può usare un altro servizio di account. Deve rifiutare account anonimi, eliminati, scaduti o comunque non validi e restituire lo stesso id di principal stabile che il client si aspetta.

Ambito e formato delle note

L'ambito di progetto si esprime con project e senza la proprietà scope:

{"project":"project-a"}

L'ambito utente ha scope: "user" e non deve contenere project:

{"scope":"user"}

Il protocollo richiede entrambi gli ambiti. L'attuale adattatore R2 incluso ha una lacuna nota nell'allowlist delle chiavi per il prefisso utente; ciò non cambia il contratto di trasmissione per le implementazioni indipendenti.

Una nota è:

{
  "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 è opzionale e, se presente, è uno tra user, feedback, project o reference. updatedAt è un metadato opzionale ed è volutamente escluso dall'hash del contenuto.

I limiti di convalida fanno parte del contratto di trasmissione:

CampoVincolo
Id di principal, progetto e nota[A-Za-z0-9_-]{1,128}; rifiuta anche __proto__, constructor e prototype
summarySenza spazi iniziali/finali, una riga, 1–500 caratteri
bodyStringa senza spazi iniziali/finali, al massimo il maxNoteBytes comunicato in UTF-8
tagsAl massimo 30; ciascuno di 1–64 caratteri senza spazi, virgole o a capo
createdAtIntero sicuro non negativo
updatedAtIntero sicuro non negativo opzionale

Gli id delle note attive sono univoci senza distinzione tra maiuscole e minuscole. È necessario perché un server non deve creare uno stato che i comuni filesystem client senza distinzione tra maiuscole e minuscole non possono rappresentare.

L'hash SHA-256 della nota, in esadecimale minuscolo, si calcola su JSON UTF-8 con esattamente questo array posizionale:

JSON.stringify([id, summary, body, tags, createdAt])

Se e solo se type è presente, aggiungilo in coda:

JSON.stringify([id, summary, body, tags, createdAt, type])

Le implementazioni devono riprodurre JSON.stringify di JavaScript per questi valori. L'eccezione di compatibilità preserva gli hash creati prima che esistessero i tipi di nota.

POST /v1/memory/snapshot

Richiesta di progetto:

{
  "expectedPrincipal": "user-id",
  "project": "project-a",
  "knownEtag": "optional-previous-etag",
  "have": {
    "borderless-cards-a91": "64-lowercase-hex-characters"
  }
}

Per l'ambito utente, sostituisci project con "scope":"user".

knownEtag consente un percorso rapido quando nulla è cambiato. have associa gli id delle note agli hash che il client possiede già; il server può omettere i corpi di quelle note senza leggerli dall'object storage. Rifiuta una mappa have con più di maxNotes * 2 + 1000 chiavi.

Risposta senza modifiche:

{
  "principal": "user-id",
  "etag": "same-etag",
  "unchanged": true,
  "quota": {
    "maxNotes": 5000,
    "maxChanges": 500,
    "maxNoteBytes": 131072,
    "tombstoneDays": 90
  }
}

Risposta completa o incrementale:

{
  "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
  }
}

Una voce attiva può omettere note solo quando have[id] è uguale al suo hash. Un tombstone ha hash: null e nessuna nota. etag è null quando non esiste alcun manifest.

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": {}
}

Per l'ambito utente, sostituisci project con "scope":"user". Una creazione o un aggiornamento deve contenere una nota il cui id e il cui hash calcolato corrispondano alla modifica. Un'eliminazione ha hash: null e nessuna note. changes deve contenere da 1 al maxChanges comunicato di voci, senza id duplicati.

Applica il commit solo se etag identifica ancora il manifest corrente. Con un ETag obsoleto restituisci 409 {"code":"revision_conflict"} e lascia invariato il manifest pubblicato. Scrivi le nuove revisioni immutabili delle note prima di pubblicare il nuovo manifest, e pubblica il manifest in modo atomico. Uno scrittore concorrente che perde può lasciare oggetti immutabili irraggiungibili per una successiva garbage collection, ma nessuno stato parziale deve diventare visibile tramite il manifest. La risposta ha la stessa forma di uno snapshot riuscito, con il nuovo ETag e le voci correnti.

Non scartare subito le eliminazioni. Conserva i tombstone per la finestra comunicata, così i client offline possono osservarli. Abbassare una quota non deve intrappolare un principal che la supera: rifiuta la crescita oltre maxNotes, ma continua a consentire le eliminazioni.

Errori e limiti della memoria

Gli errori della memoria usano un code stabile e leggibile dalle macchine:

{"code":"revision_conflict"}

I codici usati dall'implementazione attuale includono:

StatoCodici
400invalid_body, unknown_field, invalid_project, invalid_scope, invalid_have, invalid_changes, invalid_note, hash_mismatch, id_case_conflict
401sign_in_required
403native_client_required, account_mismatch
409revision_conflict
413memory_quota_exceeded
415json_required
429rate_limited
502auth_unavailable, invalid_manifest, missing_memory_object, memory_integrity_error, memory_sync_failed
503auth_not_configured, r2_not_configured

I valori predefiniti inclusi sono 5000 note attive per ambito, 500 modifiche per commit, 128 KiB per corpo di nota, 4 MiB per richiesta, 16 MiB di dati di voci serializzati per risposta e 90 giorni per i tombstone. In trasmissione vengono comunicati solo i quattro valori su cui il client può agire, mostrati in quota. Un client applica anche i propri tetti di sicurezza rigidi, quindi alzare le quote del server non garantisce che un client accetti una risposta arbitrariamente grande.

Configurare un client OHarness

Metti la destinazione nella configurazione dell'utente, mai in quella di un repository:

{
  "sync": {
    "url": "https://sync.example.com",
    "token": "opaque-session-token",
    "team": "optional-team",
    "enabled": true
  }
}

Il file è $OHARNESS_HOME/config.json quando OHARNESS_HOME è impostato, $XDG_CONFIG_HOME/oharness/config.json su Linux quando XDG è impostato, e ~/.oharness/config.json negli altri casi. Riavvia OHarness dopo aver cambiato la destinazione.

La precedenza attuale è:

sync.url in user config > OHARNESS_SYNC_URL > https://sync.oharness.ai
sync.token in user config > OHARNESS_SYNC_TOKEN > Cloud account token

Di conseguenza, OHARNESS_SYNC_URL sostituisce il valore predefinito incorporato solo quando la configurazione utente non ha sync.url. OHARNESS_SYNC=0 disattiva la sincronizzazione delle sessioni indipendentemente dalla destinazione o dalla credenziale.

I file di repository oharness.config.json e .oharness.json non possono impostare sync.url, sync.token né sync.team; OHarness scarta quei valori. Un repository può solo disattivare la sincronizzazione o aggiungere esclusioni. Questo impedisce a un repository clonato di reindirizzare le trascrizioni di un utente.

Lo stesso URL di base risolto viene usato per la memoria, ma l'attuale client della memoria usa la credenziale dell'account Cloud, non sync.token né OHARNESS_SYNC_TOKEN. Per fare self-hosting della memoria, l'accesso del client e il verificatore di identità del server della memoria devono quindi concordare sullo stesso principal.

Checklist di implementazione

Un servizio compatibile dovrebbe superare tutti questi comportamenti:

  • Il controllo di salute funziona senza token; ogni route di dati fallisce in modo chiuso senza token.
  • Due principal che usano lo stesso id di progetto non possono vedere i dati l'uno dell'altro.
  • I feed personali e quelli di team sono distinti; i team non autorizzati restituiscono 403.
  • Ritentare un invio non duplica i record né i metadati di sessione obsoleti.
  • La lettura è ordinata, paginata, con cursore monotono e sicura per il long polling.
  • I corpi delle sessioni sopravvivono a un push/pull invariati a livello di valore JSON.
  • Le eliminazioni di sessioni e di memoria raggiungono un secondo dispositivo offline.
  • Un ETag della memoria obsoleto non pubblica alcuna modifica al manifest.
  • Le chiavi degli oggetti della memoria usano sempre il principal verificato dal server.
  • Hash non corrispondenti, collisioni di id di nota che differiscono solo per maiuscole/minuscole e richieste troppo grandi vengono rifiutati prima di pubblicare un manifest.
  • Lo stato persistente di feed e manifest sopravvive a riavvii del processo e a deploy.

I test delle sessioni del repository in test/sync.test.ts e i test della memoria in script/sync-memory.test.ts sono esempi eseguibili del contratto. Al momento istanziano direttamente il server incluso invece di accettare un URL remoto, quindi un'altra implementazione dovrebbe portare gli stessi casi o fornire un piccolo adattatore prima di dichiararsi compatibile.

Per un primo smoke test remoto, emetti un token usa e getta e sostituiscine il valore qui sotto. Non usare il token di un account di produzione nella cronologia della shell:

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"

Invia lo stesso corpo una seconda volta e verifica che accepted sia 0; poi leggi dal cursore restituito e verifica che gli array siano vuoti e che il cursore sia invariato. Questi controlli accertano la raggiungibilità e l'idempotenza di base, non l'isolamento tra tenant né la sicurezza del CAS concorrente.

Cosa non è richiesto

Un server compatibile non deve:

  • eseguire un modello, eseguire strumenti o comprendere i formati dei messaggi delle trascrizioni;
  • usare JSONL, R2, Supabase o un database particolare;
  • fornire registrazione, accesso, emissione di token o un'applicazione browser;
  • implementare la memoria per poter implementare la sincronizzazione delle sessioni;
  • esporre CORS. I client attuali sono client nativi Node/Electron.

Tenere queste questioni fuori dal protocollo è ciò che permette a un piccolo servizio self-hosted e a un deployment multi-tenant di parlare lo stesso formato di trasmissione.