Skip to content

feat(protocol,gui): chat sync v2 — head/shard contract, reader, and fork resolution - #951

Merged
tanveergill merged 92 commits into
mainfrom
traycer/chat-sync-v2-train-oss
Aug 11, 2026
Merged

tanveergill merged 92 commits into
mainfrom
traycer/chat-sync-v2-train-oss

Conversation

@tanveergill

Copy link
Copy Markdown
Contributor

The OSS half of chat-sync-v2: the client⇄host wire contract for publishing and
reading chats as content-addressed shards, plus the GUI surfaces for reading a
published chat and arbitrating a forked lineage.

The internal half (host publisher, server sync surface) lands separately and
pins this commit.

What this adds

Protocol

  • chat-head + chat-shard publication records: canonical/idempotent encoding,
    major-only gating, parallel-fetch assembly, and a documented residual-capture
    contract.
  • Optional fork RPCs (host.chatFork.get / resolve) with a mandatory degrade
    story — an older host leaves the app fully functional.
  • host.notifications.indicatorState additive minor 1.1 adding pendingFork;
    frozen 1.0 is untouched and bridges to false.

gui-app

  • Cloud-chat section under the agents tree: reads a published chat fetching only
    the parts it lacks, with a content-addressed part cache.
  • Fork resolution dialog + indicator: candidates described by content, one
    prompt per fork event, dismissible with a persistent per-chat indicator.

Design notes worth knowing

  • Candidates are described by content, never by device. Both machines in a
    fork share every credential, and the decision installs a head sha rather than
    naming a machine, so device framing would be actively misleading.
  • The fork indicator is pending-class, not unread-class — it survives
    "mark all read" and restart, and clears only on resolution.
  • Protocol field additions are breaking even when nullable, so the indicator
    change is an additive minor with both minors registered.

Verification

Protocol compatibility/floor suites, gui-app suites, and compile are green.
Every behavioural fix here carries an ablation: reverting the fix turns its
regression red while the pre-existing static suites stay green.

tanveergill and others added 26 commits August 1, 2026 18:13
A published chat becomes a small mutable head stored as opaque JSON on the
chat's cloud row, plus immutable content-addressed shards the head names by
(sha256, byteLength). The head carries identity, metadata, lifecycle and run
settings; the transcript lives in message-cohort shards, so an append rewrites
one cohort instead of the whole chat.

Three things the layout turns on:

- Sections graduate. Events and the opaque hostPrivate envelope start inline in
  the head and each moves to its own part when it alone outgrows the shard
  target. A refinement enforces exclusivity in both directions - a section
  stated twice would let two readers assemble two different chats from the same
  bytes, and one stated nowhere would present as a chat that lost its event log.
  Nothing downstream of assembly can tell the two layouts apart.

- Heads carry parentHeadSha256. Ancestry is proven by identity, never by
  sequence ordering: two forked histories both number their turns, so a seq
  comparison permits exactly the dangerous local-ahead overwrite.
  throughRecordSeq stays a watermark, not an ordering authority.

- Assembly is ordered by the head, not by the fetch. Parts are fetched
  concurrently and verified per part; completion order is a network accident,
  and a part whose bytes miss the address the head named ends the read rather
  than being skipped or rendered as a gap.

The message, block and event leaves - passthrough carriers, residual bags, open
harness ids, canonical JSON - are reused verbatim from the v1 chat-snapshot
design: forward compatibility is a property of the message encoding, not of the
sync layer. Encoding is idempotent, which is what keeps an unchanged cohort's
content address stable through a reader that re-publishes it.

Both records register on ONE version line and are frozen by a schema-surface
guard. COMPATIBILITY.md carries the contract and the coupled bump ritual.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…ptly

Three fixups from cold review of the chat-sync contract.

**A shard IS a cohort.** `refineChatShardSection` rejected payload in the wrong
section but never required the SELECTED section to hold anything. An empty
`section: "events"` shard paired with a head whose events had graduated stated
an impossible graduation - a section that outgrew the head yet holds nothing -
and every integrity check passed, so it assembled as `status: "ok"` with an
empty log, indistinguishable from a chat that never had events. It also made
empty cohorts addressable objects to mint and fetch. The selected array must now
be non-empty; an empty chat is an empty shard list on the head. This is an input
narrowing, free only because nothing has shipped.

Worth stating because the frozen surfaces do NOT cover it: a Zod refinement has
no JSON-Schema form, so this regenerates to no fixture diff at all. The ritual
table now says so, in both directions.

**Assembly no longer waits on unsettled siblings.** `Promise.allSettled` held a
known failure until every sibling settled, so one stalled request became the
latency bound for every outcome - on a p99 chat's fan-out, the whole budget. The
stated rationale was also wrong: `Promise.all` installs handlers on every input,
so siblings that reject later are still observed. Fail-closed semantics are
unchanged, and the determinism that survives is documented rather than implied:
the head-earliest failure among those recorded when the read ends.

Also retargets a passthrough doc comment that still pointed at v1's removed
publication-ref for the version gate; v2 reads it off the head.

Tests: the freezing test now asserts rejection, the reviewer's end-to-end
scenario is pinned as a regression (forged through a new raw-bytes fixture
helper, since the schema will no longer build one), and three tests cover prompt
failure - transport and integrity failures each resolving while a sibling hangs,
plus the complement that a healthy read still waits for every part.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…codec

Cross-ticket integration finding: the sync server requires a top-level `parts`
array of {sha256, byteLength} - the one obligation a tenant owes it, and the
minimum a deletion mechanism can be built on, since only the head knows what a
swap displaces. The chat-head record declared three typed shard lists and no
`parts`, so a record-serialized head was not committable. Neither suite saw it:
this side never CASes, and the server's builds `{parts}` by hand.

The stored head is now a DOCUMENT - the record's canonical payload wrapped with
one derived envelope:

- `encodeChatHeadDocument` / `serializeChatHeadDocument` inject `parts` from
  `listChatHeadParts`, canonicalized with the rest. These bytes are the one
  public entry point for a head that travels, and their sha256 is
  simultaneously the CAS witness, the row's digest, and the next head's
  `parentHeadSha256`. `serializeChatHead` stays for payload-only uses and is
  not what anything is addressed by.
- `decodeChatHeadDocument` reads the envelope, STRIPS it, parses the payload
  through the reader schema, then re-derives and compares. Stripping is
  load-bearing: `parts` reaching a residual bag would be re-emitted beside a
  freshly derived one on the next publish - a stale index in the bytes the sync
  layer reads. Verification fails closed, because an envelope short an entry
  describes a swap that strands an object and one entry long describes a swap
  that deletes a live one.

Also refuses a head that names the same part twice. The server refuses one -
"displaced = previous minus current" stops being well-defined exactly where
that set drives deletion - so such a head is uncommittable, and catching it at
parse puts the failure where the publisher can see it rather than at the far
end of a swap. Two message cohorts with identical canonical bytes are the same
object anyway.

No record-shape change; the frozen surfaces regenerate byte-identical, since a
refinement and a wrapper have no JSON-Schema form. Verified end to end by
running the server's own `readDeclaredHeadParts` against documents this codec
produces: it reads exactly the derived list, its `headDigest` equals sha256 of
these bytes, and the payload-only serialization is still refused - the bug,
reproduced and closed.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Delta review's Major, and the sharpest form of the payload/document confusion:
this package's own fixture exposed `headSha256` from payload-only
`serializeChatHead`, and the lineage test asserted that digest IS the head
identity. A publisher following the fixture would chain on bytes the server
never stores, so its next sync could not find the ancestor it named and would
report a fork that never happened.

The doc comment on `serializeChatHead` said not to hash it. The fixture hashed
it anyway - which is the argument. Two functions returning canonical bytes of a
head, one stored and one not, is a trap no warning closes, so the payload
serializer is gone from the public surface entirely rather than renamed. There
is now exactly one way to turn a head into bytes and it is the right one; the
four assertions that genuinely want payload bytes compose
`canonicalJsonStringify(encodeChatHead(record))`, which reads as the deliberate
act it is.

The fixture now exposes `documentBytes` + a `headSha256` derived from it, and
the lineage test pins that identity against `serializeChatHeadDocument` from
both directions. `encodeChatHead` keeps its name - it returns a JsonObject, not
bytes, so it is not on the path to a wrong digest - with its doc narrowed to
say nothing may hash its result.

Ablations: importing `serializeChatHead` is now a compile error, and pointing
the fixture back at the payload digest turns the lineage test red.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Residual capture makes a newer minor's unmodeled fields survive an older
READER. At the `shard` level it does not make them survive an older reader
that RE-PUBLISHES - and a clone target is exactly that.

Assembly folds a publication's shards into one chat and retains only the
head's residual, so a shard record's own top-level bag never reaches a
caller. That is not an oversight to fix: cohort boundaries are a
deterministic re-cut over the current projection, so a clone re-shards
from its own state and the source's shards do not exist on the other
side. There is no coherent object left to attach a per-shard bag to.

So the rule, binding every same-major minor of `chat-shard`: a minor may
add a top-level field for per-publication bookkeeping, but must not put
load-bearing chat-level data there. Durable additions go head-level (one
per publication, carried) or message-level (inside the entries, which
assembly concatenates).

Written down because the failure is silent - an older client clones, the
field is absent from the re-published shards, and nothing reports a loss.
The client surface already records the gap as `{ kind: "unavailable" }`
rather than `dropped`; this is the writer's half of the same statement.

Documentation only. No schema, no shape, no behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
The reader for the shard layout, on the side of the wire that parses the
head. Five optional host RPCs make the local host a byte pipe -
resolve the opaque head, stream one part by digest, list and read
payloads - and every interpretation happens in the client: the version
gate, the per-part digest and length checks, head-order assembly,
presentation, and the content-addressed cache.

The host cannot verify what it will not parse. It never sees a part's
expected digest, because that number lives inside a document it is
forbidden to read, so verification has to sit where the parsing is.
Putting the cache there too is what finally makes reads incremental:
parts are immutable and named by their own bytes, so a returning
reader asks for the digests it lacks and no others. After one turn
that is the tail shard and the head. v1 refetched the whole
publication after every compaction.

Asserted as call counts on the port, never as rendered output - a
reader that ignored its cache produces a byte-identical transcript, so
only the call log can tell the two apart. The ablation (same sequence,
no cache) refetches everything, which is what makes the counts mean
something.

Failing closed, with the shapes that actually occur: a substituted
part, a truncated transfer, a tampered head, an envelope that
disagrees with its own shard lists, a part storage no longer has. A
cache hit is re-hashed rather than trusted - the store is a store, not
a trust boundary - and bytes that fail their address are never filed
under it.

Clone-not-migrate consumes the assembled chat and lifts every residual
bag the protocol declares, through an exhaustive map over the
captured-levels manifest. That manifest had no consumer until now, so
it proved only its own internal consistency; the exhaustive Record is
what makes a level added by a future minor break this build until
someone decides whether a clone carries it. hostPrivate is the one
deliberate drop - origin-host session state does not follow a chat
onto another machine - and it is reported rather than dropped
silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…refuses

The client half of the cloud-chat read, in the two shells that have one:
a Cache-API store in the renderer, a directory of files in the CLI, and
the surfaces that turn an assembled chat into something a person reads.

Both caches are the same idea and neither needs any machinery for it.
The key IS the digest, so the filename is the index and there is nothing
to keep in step; bytes never change under a name, so there is no TTL, no
version stamp and no invalidation; two processes storing the same part
write identical bytes, so a race is not one. Eviction is the browser's
quota on one side and nothing at all on the other - a heavy reader's
whole cache is megabytes, and an LRU would mean access times, which is
state that CAN be stale.

Neither store is a trust boundary. The reader re-hashes what it reads
back, so a corrupted disk entry or a colliding writer is caught rather
than served, and the CLI's digest is shape-checked before it becomes a
path - a `sha256` off a wire document must not be able to name
`../../etc/passwd`.

The surfaces state what they cannot do. An unknown block type gets a row
with the presenter's shared label, because a dropped block is
indistinguishable from a chat that never had one. A payload this reader
cannot fetch gets a sentence naming the cause, because a blank card
reads as "no changes" - both plausible and wrong. A refusal names its
own remedy: publish from the other device, update this app, open the row
you meant. And a corrupt publication surfaces the protocol's fixed
phrasing with no digest in it, because a corruption notice is a silly
place to hand back an object coordinate the read APIs deliberately
withhold.

The list rows can no longer say whether a chat is readable, and that
regression is the price of the head being opaque to the server and the
host: v1 computed the verdict from a record version the server stamped,
and there is no such stamp any more. A version refusal is discovered on
open, one click later than it used to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
The subtle decision in the reader's UI layer is an ORDERING one - the
payload list must be settled before presentation runs, because the
presenter's resolver is synchronous and the fidelity count it produces
has to be complete by the time a transcript exists. Presenting early
yields a transcript claiming zero unavailable attachments that
disagrees with itself a moment later.

That rule was a `useMemo` body, so saying it in a test meant driving
two TanStack queries into a specific interleaving - which would have
tested the scheduler as much as the decision. It is now a pure
function over four inputs, and the interesting case (chat in hand,
list still in flight, and the answer is still "loading") is one
assertion.

Also pins the second half of the rule: settled is not successful. A
FAILED list is an answer, and the transcript degrades to exactly the
markers it drew before the payload channel existed rather than holding
a fully-downloaded chat behind a call that is not coming back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Below the local tree and OUTSIDE its empty-state branches, which is the
placement the surface exists for: "no agents yet" is a statement about
THIS device, and the case where it is true while another device has
published chats is precisely the one a reader opens the sidebar to
find.

`taskId` is the epic id directly - in the 3.0 model an epic id IS the
task id, which is what the publisher's own cloud calls assume - so
there is no mapping layer and there should not be one.

The dedup set is the epic's own chat PROJECTION, nested chats included,
not the host's chat registry. The question the section asks is "what
does this sidebar already show", and a chat the registry knows but the
projection has not materialized would be hidden from both surfaces if
the registry were the authority. It comes through `useEpicChatIds`,
which returns a sorted array rather than a Set for the reason
`useEpicArchivedNodeIds` already gives: chat projections churn on every
title and `updatedAt`, while the id set moves only on create/delete,
and a freshly-allocated Set would defeat `useShallow` and re-render the
tree on all of it.

The hide rule moved into a pure function, and doing so caught a real
defect on the way: a DISABLED TanStack query reports `isPending`
forever, so a section keyed on pending alone would have shown
"Checking your other devices…" to every signed-out user, permanently.
`isFetching` is what separates in-flight from never-started, and only
the first earns a spinner.

The selection-mode suite gains a stub for the section's list. Leaving
it real would need that suite to supply a QueryClientProvider AND a
host-client stub complete enough for `useReactiveHostReadiness` - for a
section that renders nothing there either way. The component itself
stays real, so its hidden path is still exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…honest

Three defects the cold review found, all of which shared a shape: a
property proved one layer down and not held at the surface that needed
it.

**Reopen never re-resolved.** The head read was keyed by identity with
`staleTime: Infinity`, and closing the dialog only disabled that same
cached query. Open H1, publish H2, reopen — TanStack answered from
memory and the reader saw H1 having made zero requests. The driver
tests were right and irrelevant: the incremental read held in the
pipeline and was unreachable from the one surface built for it.

Fixed with `gcTime: 0` beside the existing `staleTime: Infinity`. They
are a pair: the first makes an open dialog a point-in-time copy, and
the second makes "picked up by reopening" true, because the dialog's
body unmounts on close and the query loses its last observer. Chosen
over invalidating on the open transition because it needs no caller to
remember, and because an on-mount invalidation costs a second fetch on
a cold open. Dropping the assembled chat re-costs only the head: the
parts are content-addressed and live in a store this query does not
own. The new mounted test counts resolves and part reads across
open→publish→reopen and was red before the fix.

**Payload bytes rendered unverified.** The dialog held the ref, dropped
it, and rendered whatever came back under it; only base64 validity and
the host-declared length were checked, so a same-length substitution
rendered as the named content. Length cannot establish content
identity. The fetch and the verification are now one operation inside
the query function, so nothing downstream can obtain payload bytes
that were not hashed against the address they were requested by — the
same shape the shard path already has. Same-length and single-flipped-
byte tests, both red without the check.

**The OSS compile gate was red and I reported it green.** A test helper
took `code: string` into the concrete `RpcErrorCode` union. Two
mistakes stacked: I leaned on the per-package `tsc --noEmit` (weaker
than `tsc -b`, which I had documented an hour earlier), and my gate
command was `bun run compile | grep -c "error TS"` — which never
matches, because Nx colorizes the output and an ANSI reset sits
between "error" and "TS". A grep proxy for an exit code, exactly the
false-green class this repo already had on record. Gates now capture
exit codes.

Also from the review: the two shard-cache hash tests were false
witnesses — both used wrong-LENGTH corruption, so the length guard
passed them with the digest checks deleted; they now use same-length
mutations and each ablates red. Payload rows degrade to the unavailable
marker on a settled failure instead of spinning forever. The encoded
body is bounded before `atob`, so a response declaring ten bytes cannot
buy a multi-hundred-megabyte expansion. Event rows render, because the
fidelity warning was counting them. A literal NUL byte in the payload
resolver made Git treat that source as binary; it is the `\0` escape
now. `path.dirname` replaces a literal `/` search that would have
silently disabled the CLI cache on Windows. Two key builders with no
consumer are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…nding

Chasing the question of whether `gcTime: 0` survives StrictMode's
double mount, I wrote a test that wrapped the dialog in `<StrictMode>`
and asserted one head resolve. It passed. It was worthless: a probe in
this package counts ONE mount effect with `<StrictMode>` and one
without, so the wrapper changes nothing here and the assertion holds
whether or not the property does.

That is the same false-witness class the review just caught in two
cache tests - a fixture that cannot distinguish the fix from its
absence - and it would have been worse here, because it would have been
offered as evidence FOR a deviation rather than sitting quietly in a
suite.

So the test is gone and the limit is written down instead. The
double-mount class is verified in the dev app; jsdom cannot speak to
it, and a suite that implies otherwise is worse than one that admits
the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
`git diff --check` flagged it. Fixed now rather than left for the
integration task to trip over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Published chat parts are cached content-addressed on the reading device -
in the renderer's Cache API store and under the CLI's shared home - and
both stores are deliberately shared across every viewer on the
installation. That sharing is sound while people are signed in: a part is
named by the sha256 of its own bytes, so the only way to learn an address
is to resolve a head the server authorized you for, and partitioning per
viewer would buy no confidentiality while costing the whole dedup.

It is not sound as a residue. "Leave the account" reasonably means "leave
the content", and the cost of honoring that is one cold read next time.

Both clears ride the CONFIRMED sign-out, never the attempt. The GUI hook
sits in `signOut()` after the credential delete lands rather than in
`applySignedOut()`, which also runs for the UI-only signed-out projection
a dead credential produces - the file is kept there and the same user is
one refresh from being back. The CLI awaits its clear, because unlike the
renderer there is no process left to finish the work.

One spelling of "which CacheStorage" now, in `browserChatPartCacheStorage`:
there are two callers with opposite jobs, and two copies of that probe is
how one of them ends up looking at a different store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Pure formatting, no semantic change. `bun run format` rewrote these on the
first run over this branch, which is how it became visible that the reader
and client-surface work landed while the formatter had not run - the files
were never prettier-clean.

Kept as its own commit rather than folded into the work beside it: mixing
formatter output into a feature diff hides both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…ner resolve it

Two new optional host RPCs and a third, independently-optional read:

- host.chatFork.get - the current host-level fork event, mirrored verbatim
  from 07's ChatForkEvent (candidates described by content only: turn
  count, last activity, part count - never a device). Registered
  degrade:unsupported alongside host.chatFork.resolve, since a dialog that
  could observe a fork but not resolve it is worse than no dialog.
- host.chatFork.resolve - submits the owner's pick (keep-cloud-lineage |
  keep-this-host-lineage) for the CURRENT open episode by episodeId; a
  stale episodeId (already decided, or superseded) answers `stale` rather
  than trusting client state.
- host.chatFork.readCandidateHead - lets the owner inspect their own
  quarantined candidate on demand. Degrades independently: it's the
  dialog's "view" link, not the dialog's ability to function.

gui-app: a global, non-blocking dialog (mounted once in AppShell, opened
via the shared app-dialog-store) implementing the three settled UX
rulings - notification-adjacent indicator -> dialog, candidates by
content, dismissible with the indicator persisting and no re-prompt on
reconnect/re-render (the dialog never auto-opens; only explicit clicks
open it). A persistent global banner is the interim indicator; wiring a
per-chat sidebar badge into the existing NotificationIndicatorState rollup
is left as a follow-up rather than a rushed edit to that system under this
change's scope.

The OS-toast/notification-center entry point from decision-log ruling #1
is deliberately NOT wired in this commit: emitting a host-local
notification needs a resolved userId, and the fork event (spanning
arbitrary chats/tasks) has no straightforward one from the onFork
callback's scope. Left as a flagged follow-up rather than guessed at.

Gates: `bun run --filter '@traycer/protocol' compile` and
`bun run --filter '@traycer-clients/gui-app' compile` exit 0; protocol
suite 120 files / 1775 tests green; new chat-fork-dialog test (5 tests)
green.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…y content

Cold-review fixups (round 1, four findings):

- H1: the fork banner could stay silent forever for a post-mount fork.
  useHostQuery's E_HOST_UNSUPPORTED retry suppression stops a doomed
  RETRY loop, but nothing invalidated the FIRST cached answer - no
  host-pushed channel exists for this event (the OS-toast path was
  deliberately not wired; see the host-side report). host.chatFork.get
  now polls at a 45s table-owned cadence (HOST_METHOD_POLL_TABLE +
  poll: true) rather than the policy table's `poll: null` lying about a
  push that never happens.
- H2: optional-method degradation was declared in the registry but never
  consulted client-side. The whole surface (banner included) now gates
  on host.chatFork.get AND .resolve both being negotiated
  (useHostSupportsMethod); the "View" link gates on
  readCandidateHead independently, matching its own degrade story; an
  RPC error on the candidate preview renders as an error, not
  "unavailable" (query.error was previously ignored).
- M3: copy said "This device" / "Keep this device's history" - a literal
  violation of the by-content-never-device ruling. Candidates are now
  labeled "Published" / "Candidate", the option copy says "Keep the
  candidate's history", and the empty state says "No candidate is
  available" - no device or host word anywhere outside the (unchanged)
  diagnostic cause hint.
- M4: only the candidate side had a "View" link; the incumbent had none,
  and the ruling requires both equally inspectable. Both cards now carry
  their own control. The "Refresh view" relabel did nothing against a
  staleTime: Infinity query - removed; the control just hides once
  inspecting, matching what it can actually do.

M4 correction (raised in the same review pass): the View link renders
the candidate's raw head record, not a readable chat transcript, which
the reviewer correctly flagged as not "inspecting the chat". The data
path IS composable without new RPCs - useCloudChatRead's
CloudChatReadPort takes an identity and a resolveHead/readPart pair, so
a small adapter could resolve to the candidate's specific head instead
of the current one, then reuse readPart unchanged (parts are
content-addressed and already live in the same chat-scoped storage).
What this pass did NOT locate, under its time budget, is which existing
component actually RENDERS an AssembledChat, so wiring the adapter in
was left undone rather than guessed at. The preview is now labeled
"Raw record (not a readable transcript)" so it does not misrepresent
itself, and the gap is flagged to the assigning agent for a follow-up
rather than shipped as if it were the transcript view.

Also threads the resolver's per-chat outcome onto the new 3-way
ChatForkResolveChatOutcome (resolved | stale | not-ready) from the
internal amendment landing alongside this commit, and gives the
dialog's terminal state a real "still finalizing, try again" path for
the not-ready case instead of only ever showing a confirmation.

Gates: `bun run --filter '@traycer/protocol' compile` and
`bun run --filter '@traycer-clients/gui-app' compile` exit 0. protocol
suite 120/1775 green. gui-app chats/host-rpc-policy/dialog-store
suites: 28/28 green (dialog test updated for the new
useChatForkReadCandidateHeadSupported export and isError channel).

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…ay, drop challenger raw-read

Round 3 of chat-sync-v2 ticket 09's fork-resolution review, plus its
addendum and a scope-reduction ruling that arrived mid-pass:

- Protocol schema: ChatForkChatNotice now carries its own repairEpoch
  (an episode can bundle chats at different eras; the old episode-level
  scalar could not fence them independently).
- The dialog no longer offers an option whose winners map is empty for
  every displayed chat, and the resolved-confirmation screen no longer
  reads an empty results array as "Decision recorded" (Array.every is
  vacuously true on []).
- Copy: removed the last device/host-identity wording from the option
  detail strings and the not-ready confirmation text; the dialog test
  now asserts NO device/host wording anywhere except the one sanctioned
  diagnostic sentence.
- Published/incumbent card: now opens the ordinary CloudChatDialog
  (the same reader the "other devices" sidebar list uses) instead of a
  dead link into the challenger-only candidate-head endpoint.

Scope reduction (ruled mid-pass): the challenger card no longer offers
any content read at all - it is identified by its existing summary
metadata (turn count, last activity, part count), human-formatted via
the shared relative-time hook. This deletes
host.chatFork.readCandidateHead from the protocol (never released,
free to remove), its resolver wiring, its GUI query hook, and the raw
head-record preview component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Round 4's settled identity spec, OSS half.

- protocol: `ChatForkChatNotice` carries `forkOccurrenceId`, the host-minted
  identity of one fork occurrence. Epoch and candidate digest are assertions
  about a lineage, not about an occurrence: two genuinely different forks on
  one chat can coincide on both, and only this tells them apart. Not renderer
  content - the host reads it back off its own held event when `resolve` fans
  out - but mirrored like every other field, because this schema is that
  event's one shape.
- protocol: drop the episode-level `repairEpoch` from `ChatForkEvent`. A
  scalar cannot fence an episode whose chats sit at different eras; the
  per-chat `repairEpoch` already does. Unreleased, so free to remove.
- gui-app: the candidate option now covers candidate-less chats too (the host
  maps them to their incumbent - there is no candidate content to crown), and
  discloses the asymmetry in its own copy rather than leaving the owner to
  discover it from a confirmation screen.
- gui-app: pin that success is claimed only when EVERY per-chat outcome
  resolved - a partial set, and an empty one, are both "still finalizing".
  `every` on an empty array is vacuously true, which is how the empty case
  used to render as a recorded decision.

Verified: protocol + gui-app compile; gui-app chats suite green (9 tests);
the success-claim assertion goes red on ablating the non-empty guard.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
…ll/.traycer/worktrees/traycerai__traycer-internal/traycer-chat-sync-v2-15-fork-surface/traycer into traycer/chat-sync-v2-train-oss

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary by CodeRabbit

  • New Features
    • Added read-only published chat tiles with cloud transcript viewing.
    • Added unified sidebar chat lists combining local and cloud chats.
    • Added published file diffs, plans, payload previews, and fidelity notices.
    • Added fork-status and backup-status indicators.
    • Added offline, access-state, and lock messaging for published chats.
    • Added resilient cross-host chat cloning with history-unavailable guidance.
  • Bug Fixes
    • Improved handling of unavailable, corrupted, unpublished, or unsupported content.
    • Protected published chats from accidental deletion.
    • Signing out now clears cached chat content after successful credential removal.

Walkthrough

This PR adds versioned chat-sync persistence, verified cloud-chat reads, host fork observation, notification-state merging, content-addressed caching, published-chat rendering, unified sidebar rows, backup status indicators, and read-only canvas tiles.

Changes

Chat-sync persistence and compatibility

Layer / File(s) Summary
Persistence contracts and assembly
protocol/src/persistence/chat-sync/*, protocol/src/persistence/registry.ts
Adds versioned chat-head and chat-shard records, canonical encoding, residual preservation, passthrough handling, reader gating, concurrent assembly, and presentation models.
Compatibility validation
protocol/src/persistence/COMPATIBILITY.md, protocol/src/persistence/chat-sync/__tests__/*
Adds schema baselines and tests for compatibility, unknown variants, canonical JSON, content addressing, lineage, open harness IDs, and fail-closed assembly.

Cloud-chat transport and caching

Layer / File(s) Summary
Host contracts and verified reads
protocol/src/host/epic/cloud-chat.ts, clients/shared/cloud-chat/*
Adds cloud-chat list, head, part, payload, publication, replica, and identity contracts with digest and length verification.
Renderer queries and payload presentation
clients/gui-app/src/hooks/chats/*, clients/gui-app/src/lib/chats/cloud-chat-*
Adds viewer-scoped queries, transcript state composition, payload decoding, refusal mapping, and transcript display.
Part caches and cleanup
clients/gui-app/src/lib/chats/cloud-chat-part-cache.ts, clients/traycer-cli/src/store/chat-part-cache.ts, clients/gui-app/src/lib/auth/auth-service.ts, clients/traycer-cli/src/commands/logout.ts
Adds browser and disk caches with safe keys, atomic writes, graceful failures, and cleanup after successful sign-out.

Fork indicators and published-chat UI

Layer / File(s) Summary
Fork observation and indicators
protocol/src/host/chat-fork/*, clients/gui-app/src/hooks/chats/use-chat-fork-queries.ts, clients/gui-app/src/stores/notifications/*
Adds fork-event polling, lifecycle invalidation, fork notification precedence, and host-authoritative pending-fork merging.
Unified sidebar and published tiles
clients/gui-app/src/components/epic-canvas/sidebar/*, clients/gui-app/src/lib/chats/unified-chat-list.ts, clients/gui-app/src/stores/epics/canvas/*
Adds cloud-chat sidebar rows, publication-target folding, published-chat tile identities, lock states, backup status indicators, and unified local/cloud ordering.
Read-only transcript rendering
clients/gui-app/src/components/epic-canvas/renderers/published-chat-*.tsx, clients/gui-app/src/lib/chats/published-chat-*.ts*, clients/gui-app/src/components/chat/segments/*
Adds published transcript sessions, refusal notices, deferred payload loading, truncation notices, and read-only composer messaging.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

  • traycerai/traycer#3635 — Covers host-bound tabs, reachability handling, and clone-on-host-switch behavior.

Possibly related PRs

Suggested labels: protocol-compat-override

Suggested reviewers: hdkshingala

Poem

A rabbit guards each cloud-chat shard,
Fork-lit notices stand on guard.
Caches clear when sign-out is done,
Published tiles lock out the sun.
Unknown blocks keep their place,
Safe transcripts hop through space.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main protocol, GUI chat-sync, and fork-resolution changes.
Description check ✅ Passed The description directly explains the protocol and GUI changes covered by the pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch traycer/chat-sync-v2-train-oss

Comment @coderabbitai help to get the list of available commands.

…train-oss

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5cc0dca316

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread clients/gui-app/src/components/chats/chat-fork-dialog.tsx Outdated
Comment thread clients/shared/cloud-chat/cloud-chat-reader.ts Outdated
Comment thread protocol/src/persistence/chat-sync/version.ts
…elds

The frozen-surface gate fired after merging main: the shard domain schema
gained `backgroundTask` and `stopped` on the command block, plus the
`managedCommand` shape, from main's terminal/background-task work.

Classified per the rule in `persistence/chat-sync/shard.ts`, which binds
every same-major minor of `chat-shard`: a minor must not put load-bearing
chat-level data in a TOP-LEVEL shard field, because assembly folds shards
into one chat and a clone re-shards from its own projection, so that bag
never reaches a re-publishing reader. Durable additions belong head-level
or MESSAGE-level - "inside the entries, which assembly concatenates".

These fields are message-level: they sit on a command block inside the
entries, which is exactly where the rule places durable additions. So this
is a compatible same-major additive change, and the gate is a review gate
rather than a breakage claim. Regenerated with
protocol/scripts/snapshot-chat-sync-schema-surface.ts; protocol suite
134 files / 1875 tests green.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
@tanveergill

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f49af57a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread clients/gui-app/src/components/epic-canvas/renderers/chat-tile.tsx
Comment thread clients/gui-app/src/hooks/chats/use-chat-publication-targets.ts
…train-oss

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
@tanveergill

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75195e322f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread clients/gui-app/src/lib/chats/published-chat-source.tsx
The shared predicate needs no export - both readers live in this file -
and exporting a non-component from a component file trips
react-refresh/only-export-components, which the lint gate counts as a
failure.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
@tanveergill

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

CI's pre-commit runs prettier over --all-files and fails when the hook
modifies anything; the local affected-scope run missed these four.

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
@tanveergill

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…train-oss

Signed-off-by: Tanveer Gill <tanveer@traycer.ai>

# Conflicts:
#	clients/gui-app/src/components/epic-canvas/sidebar/epic-sidebar-chat-tree.tsx
@tanveergill
tanveergill force-pushed the traycer/chat-sync-v2-train-oss branch from e2da1ae to c802946 Compare August 11, 2026 17:27

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
clients/gui-app/src/components/epic-canvas/hooks/use-epic-route-synchronization.ts (1)

95-100: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the tab-bound host for route synchronization.

This hook manages the canvas for tabId. It reads both the cloud client and liveness host from app-wide active-host hooks. An active-host switch can then evaluate a tab-bound canvas against another host and close a valid tile.

Use useTabHostClient() and useTabHostId() for this tab scope. Keep the cloud query and isTileRefRecordLive host argument bound to that host for the tab lifetime.

As per coding guidelines, use useTabHostId()/useTabHostClient() for tab-tile scope and do not mix app-wide active-host scope into tabs.

Also applies to: 422-427

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@clients/gui-app/src/components/epic-canvas/hooks/use-epic-route-synchronization.ts`
around lines 95 - 100, Update the tab-scoped route synchronization hook to use
useTabHostClient() and useTabHostId() instead of app-wide active-host hooks.
Bind the cloud chat query and the isTileRefRecordLive host argument to this tab
host for the tab’s lifetime, ensuring no active-host state is used in the
tab-tile scope.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@clients/gui-app/src/components/epic-canvas/hooks/use-epic-route-synchronization.ts`:
- Around line 95-100: Update the tab-scoped route synchronization hook to use
useTabHostClient() and useTabHostId() instead of app-wide active-host hooks.
Bind the cloud chat query and the isTileRefRecordLive host argument to this tab
host for the tab’s lifetime, ensuring no active-host state is used in the
tab-tile scope.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3aadff30-a5de-4bf3-8305-012190c18a4e

📥 Commits

Reviewing files that changed from the base of the PR and between 410e774 and c802946.

📒 Files selected for processing (17)
  • clients/gui-app/src/components/epic-canvas/__tests__/use-epic-route-synchronization.test.tsx
  • clients/gui-app/src/components/epic-canvas/hooks/use-epic-route-synchronization.ts
  • clients/gui-app/src/components/epic-canvas/sidebar/__tests__/epic-sidebar-cloud-chat-row.test.tsx
  • clients/gui-app/src/components/epic-canvas/sidebar/__tests__/epic-sidebar-selection-mode.test.tsx
  • clients/gui-app/src/components/epic-canvas/sidebar/epic-sidebar-chat-tree.tsx
  • clients/gui-app/src/hooks/chats/__tests__/cloud-chat-list-sweep-authorization.test.ts
  • clients/gui-app/src/hooks/chats/__tests__/use-chat-replica-read.test.tsx
  • clients/gui-app/src/hooks/chats/use-chat-replica-read.ts
  • clients/gui-app/src/hooks/chats/use-cloud-chat-queries.ts
  • clients/gui-app/src/lib/commands/actions/__tests__/profile-durability-clone-host-switch-edges.test.ts
  • clients/gui-app/src/lib/commands/actions/clone-chat-on-host-switch.ts
  • clients/traycer-cli/src/commands/logout.ts
  • clients/traycer-cli/src/store/__tests__/chat-part-cache.test.ts
  • clients/traycer-cli/src/store/chat-part-cache.ts
  • protocol/src/host/epic/__tests__/cloud-chat-head-response-invariant.test.ts
  • protocol/src/host/epic/cloud-chat.ts
  • protocol/src/host/epic/index.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c8029463e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@tanveergill
tanveergill merged commit 4f37e7e into main Aug 11, 2026
20 checks passed
@tanveergill
tanveergill deleted the traycer/chat-sync-v2-train-oss branch August 11, 2026 18:04
hdkshingala added a commit that referenced this pull request Aug 11, 2026
Brings in Chat-sync v2 (#951) and the three fixes above it. One conflict,
in `notification-row.tsx`: both sides added a hook call at the same line
against an empty base - `useReactiveLocalHostEntry` /
`useRunnerHostOrNull` here, `useReducedMotion` upstream. Resolved as the
union, which is what the body needs: it reads `localHost` / `runnerHost`
for the pack-attribution presentation and `shouldReduceMotion` for the
layout animation and the relocation highlight.

That union is also why this commit carries a change no side wrote.
`NotificationRow` came out at complexity 19 against the module's ceiling
of 16 - neither side crossed it alone. The four reduced-motion ternaries
move into `rowMotionProps`, which is one decision expressed once rather
than the same conditional spelled four times. Raising the ceiling would
have been the other option, and the wrong one.

`host-method-policy-table.ts` and `protocol/src/host/registry.ts`
auto-merged; both keep this branch's four pack-version RPC entries
alongside main's chat-sync additions.

- lint at `--max-warnings 0` — clean
- `bun run --cwd traycer compile` (protocol, gui-app) — clean
- protocol suites: 142 files, 2109 passed
- gui-app settings/notifications/providers suites: 102 files, 1323 passed

Signed-off-by: Hardik Shingala <hardik@traycer.ai>
hdkshingala added a commit that referenced this pull request Aug 11, 2026
Base sync: picks up chat-sync v2 (#951) and the notification mark-all-read
fix (#1117). No textual conflicts; the only overlap with this branch is
auth-service.ts, where main's addition is a self-contained
clearChatPartCache() call in the signOut delete-landed path, independent
of this branch's recovery-loop settle.
ElliotWood added a commit to ElliotWood/traycer-remote-mobile that referenced this pull request Aug 29, 2026
…test

The Tests workflow ran every project with `--tui=false`, which leaves
Nx's default output style in place: the child's stdout is buffered into
an internal stream and replayed AFTER the failure banner, capped. On the
fork that has produced, on identical docs-only trees, three red gui-app
shards whose logs end mid-write with no test named (runs 32999100333,
33122275631, 33147777425) and two whose logs name the file -
`providers-settings-panel.test.tsx`, the same 39 of 74 both times
(runs 32957853364, 33238440979) - but are cut before the assertion text
that would say why. The file passes 74/74 locally on this tree.

`--outputStyle=stream` sends the output straight to the step log in
causal order. It replaces `--tui=false` rather than joining it: Nx
rejects the pair ("Arguments tui and outputStyle are mutually
exclusive") and exits 1 before running anything - verified against this
tree's nx ^22.7.8, which is also upstream's. Upstream made the same
change in traycerai#951 (the `test` job, 2026-08-11) and traycerai#1552 (the darwin job,
today); on both lines the fork was unchanged from the merge base, so
the merge already auto-took upstream's line. Against upstream/main at
95cfe2a the 50 conflicted paths are unchanged, only test.yml's
stage-2 (ours) OID moves, and its three conflict blocks are untouched
(9 markers before and after; the Test lines are outside them and now
identical on both sides).

Ticket: docs/autobuild/ci-tests-flake.md, ask (1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant