feat(protocol,gui): chat sync v2 — head/shard contract, reader, and fork resolution - #951
Conversation
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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Summary by CodeRabbit
WalkthroughThis 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. ChangesChat-sync persistence and compatibility
Cloud-chat transport and caching
Fork indicators and published-chat UI
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
…train-oss Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
There was a problem hiding this comment.
💡 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".
…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>
|
@coderabbitai review |
|
There was a problem hiding this comment.
💡 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".
…train-oss Signed-off-by: Tanveer Gill <tanveer@traycer.ai>
|
@coderabbitai review |
|
There was a problem hiding this comment.
💡 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".
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>
|
@coderabbitai review |
|
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>
|
@coderabbitai review |
✅ Action performedReview finished.
|
…train-oss Signed-off-by: Tanveer Gill <tanveer@traycer.ai> # Conflicts: # clients/gui-app/src/components/epic-canvas/sidebar/epic-sidebar-chat-tree.tsx
e2da1ae to
c802946
Compare
There was a problem hiding this comment.
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 winUse 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()anduseTabHostId()for this tab scope. Keep the cloud query andisTileRefRecordLivehost 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
📒 Files selected for processing (17)
clients/gui-app/src/components/epic-canvas/__tests__/use-epic-route-synchronization.test.tsxclients/gui-app/src/components/epic-canvas/hooks/use-epic-route-synchronization.tsclients/gui-app/src/components/epic-canvas/sidebar/__tests__/epic-sidebar-cloud-chat-row.test.tsxclients/gui-app/src/components/epic-canvas/sidebar/__tests__/epic-sidebar-selection-mode.test.tsxclients/gui-app/src/components/epic-canvas/sidebar/epic-sidebar-chat-tree.tsxclients/gui-app/src/hooks/chats/__tests__/cloud-chat-list-sweep-authorization.test.tsclients/gui-app/src/hooks/chats/__tests__/use-chat-replica-read.test.tsxclients/gui-app/src/hooks/chats/use-chat-replica-read.tsclients/gui-app/src/hooks/chats/use-cloud-chat-queries.tsclients/gui-app/src/lib/commands/actions/__tests__/profile-durability-clone-host-switch-edges.test.tsclients/gui-app/src/lib/commands/actions/clone-chat-on-host-switch.tsclients/traycer-cli/src/commands/logout.tsclients/traycer-cli/src/store/__tests__/chat-part-cache.test.tsclients/traycer-cli/src/store/chat-part-cache.tsprotocol/src/host/epic/__tests__/cloud-chat-head-response-invariant.test.tsprotocol/src/host/epic/cloud-chat.tsprotocol/src/host/epic/index.ts
There was a problem hiding this comment.
💡 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".
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>
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.
…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>
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-shardpublication records: canonical/idempotent encoding,major-only gating, parallel-fetch assembly, and a documented residual-capture
contract.
host.chatFork.get/resolve) with a mandatory degradestory — an older host leaves the app fully functional.
host.notifications.indicatorStateadditive minor 1.1 addingpendingFork;frozen 1.0 is untouched and bridges to
false.gui-app
the parts it lacks, with a content-addressed part cache.
prompt per fork event, dismissible with a persistent per-chat indicator.
Design notes worth knowing
fork share every credential, and the decision installs a head sha rather than
naming a machine, so device framing would be actively misleading.
"mark all read" and restart, and clears only on resolution.
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.