storefix(types): close 205 typecheck errors by repairing real client/server drift
apps/mail typechecked against a stale apps/server/dist d.ts (the server's
package.json points "types" at dist), so a chunk of the reported drift was
phantom. Rebuilt it and fixed the four script errors that blocked `tsc -b`
in server, which is what regenerates it.
The rest was genuine contract drift, fixed at the source rather than cast
away — routes that dropped fields their callers still read (agentExecutions,
getUpcomingCalendarEvents' conversationId, createCanvas' description),
schema splits the UI never followed (conversationUsers vs
conversationContacts), and enums that grew server-side but not client-side
(TASK_TYPES, ActionStatus, DateFilterOperator).
Also deletes code that was already dead: an unreferenced sort popover whose
store API is gone, a panel importing a deleted module, the system-skill
metadata UI whose mutation was removed deliberately, and a test asserting a
store method that no longer exists.
apps/server: 4 -> 0. apps/mail: 228 -> 23, all remaining errors being
dependency resolution (zod v3/v4, react-router dev/runtime skew, tiptap
v2 via novel) rather than code.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 7:45 PMutilsfix(crm): "3h ago", not "Today", for everyone west of UTC
`formatRelativeDate` read two different calendars. The day branch built its comparison from
UTC components — deliberately, so a date-only value stored at midnight UTC does not slide
back a day for US viewers — and said so in a comment ending "BOTH sides must read the same
clock". The sub-24h branch three lines above it asked date-fns `isToday`, which reads the
LOCAL calendar. The one branch nobody re-checked was the one the comment was about.
West of UTC the two disagree for most of the evening. At 04:37Z on the 31st — 00:37 in New
York — a touch from 3h41m earlier is 20:55 on the 30th locally but still the 31st in UTC. So
`isToday` was false, the hour branch was skipped, and the day math, which had both on the
31st, answered "Today". A rep opening a deal after 8pm saw a meeting from that afternoon
labelled as though the hour did not matter.
The UTC day values are now computed once, above both branches, and the hour branch tests
those instead of the ambient zone. `isToday` is gone with it.
── Why the suite could not catch this ──
Every CI runner is UTC, and in UTC the two calendars cannot disagree, so all five existing
tests passed on the broken code. The suite even documented the hole — "keys off the viewer's
LOCAL calendar day by design, so it is not timezone-stable" — right under a test asserting
"3h ago", which is exactly the assertion that is not stable. UTC is not a neutral default for
date code; it is the blind spot.
So there is now a second pass, west of UTC: `tests/timezone/`, run by
`pnpm --filter @zero/mail test:tz` and by its own CI step, excluded from the main run so it
cannot pass vacuously there. TZ has to be set before the process starts — V8 caches the zone
on first use, so neither assigning `process.env.TZ` in a test nor a custom testEnvironment can
move it (I tried both; a guard test in the file now fails if the zone ever stops applying).
Verified the way a regression test has to be: the new suite fails on the old implementation
with exactly "Expected 3h ago, Received Today", and passes on the fix.
Co-Authored-By: Claude Opus 5 <<email>>Sep 8, 2026, 1:51 PMaop-refresh-indicator.test.tsfix(crm): stop AOP changes dying on CloudFront's 60s origin timeout
Changing a conversation's AOP awaited the full agent refresh (Gmail re-fetch +
conversation-updating LLM skill) inside the tRPC mutation. Measured in prod over
14 days that refresh runs p50 28.4s / p90 89.6s / max 176.4s, against a 60s
CloudFront origin readTimeout — so 29% of AOP changes had their connection
severed and surfaced in the browser as a raw "api.mail.cedarcopilot.com failed",
even though the aopId write had already committed and the server-side span
finished OK.
The slow half now lives in services/aop/aop-change-refresh.ts and runs detached:
the mutation returns once aopId and the audit row are durable. Its two steps are
independently guarded, so a failing refresh no longer skips the Gmail label sync,
and failures go to logError instead of vanishing into a detached promise.
Backgrounding it hides ~30s of field churn from the user, so the refresh's
execution row is opened in the request and its runId returned as aopRefresh.runId
(handleConversationRefresh takes it via existingRunId rather than opening a rival
row). The client tracks it as aopRefreshRunId to show "Updating fields…" and pull
the refreshed values in when the run lands. This polls rather than listens:
sseEventBus's subscription-manager has no importers, so there is no live push
channel to the client for executions today.
Also: the update-failure toast no longer pipes the raw fetch error into the UI —
it names the field, strips hostnames, and for a dropped connection says the change
may have saved rather than claiming it failed. And drops a premature conn.end()
that ran mid-procedure while db was still in use (harmless only because the
shared-pool handle's end() is a no-op counter decrement).
Report: apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md, which
also lists the other buffered routes on the same cliff — integrations.connect
(18% over 60s) and crm.loadConversation (p95 60.5s) are the notable ones, both
left untouched here.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 7, 2026, 2:51 PMaopChangeConsistency.test.tsxperf(crm): give conversation rows their own event projection
A row draws a dot timeline and three "last <type> at" columns, and nothing
else — but crm.listConversations was building up to seven nested sub-objects
per event through eight LEFT JOINs to feed it. Rows now get five scalars and a
flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw
three times the history for no more bytes: measured on a 50-row page, 547KB
over 641 events before, 538KB over 1,919 now.
That cap was also quietly wrong. "Last meeting" was derived by scanning
whatever events the list happened to ship, so a deal whose last meeting fell
outside its 15 most recent rendered blank — 53 of the 304 conversations with a
meeting, in the org this was measured on — while SORTING by the same field used
an unbounded MAX(occurred_at) and placed it somewhere the column contradicted.
The three columns now read scalars computed over the whole history, from the
same expressions the ORDER BY builder already used.
Splitting the projections needed guards the single shared type had been hiding:
- setConversations takes an explicit `projection`. Both queries write one store
key and replace wholesale, so a list refetch would otherwise empty the
timeline of whatever deal was open — the same bug that previously just shrank
it to 15 (client-data-architecture.md §1.2).
- getEventType/getEventTitle read `eventType` rather than sniffing which
sub-object came back from a join, which equated "this join returned nothing"
with "this is a note" and would have greyed out every dot.
- a dot click opens EventDetailDialog on an id and fetches the full event,
since the row no longer carries one.
Also carries the in-flight pending-conversation-field-writes mask and its
tests: it edits the same setConversations body, so it cannot be split out.
Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PMconversation-update-error.test.tsfix: close the stale-state holes in the AOP-async and mail perf changes
Review follow-ups on the detached AOP refresh and the three caches it shipped
alongside. Each one leaves state that outlives the thing it describes.
Orphaned execution rows. The refresh row is the only signal telling the client
whether the agent is still working, and three paths could strand it in
`executing` forever: handleConversationRefresh adopted `existingRunId` after its
first await, so an early throw closed nothing; handleChangeAopForConversation
discarded the handler's return value, and that handler reports failure by
returning rather than throwing; and a deploy mid-refresh killed the work with no
one to close the row. Fixed by adopting the runId up front, adding a
`refreshError` channel that does not fold into `success` (the AOP change itself
did commit), and draining in-flight refreshes from gracefulShutdown.
Connection-record cache. Its own header said every writer of the token columns
must invalidate; `clearInvalidConnectionTokens` and `resetConnection` did not,
and no delete path did at all — so "Disconnect mailbox" kept serving that row's
OAuth tokens for the rest of the TTL. Covered those, made the generic
`updateConnection` setter invalidate unconditionally, froze the cached row
(every hit shares one reference), and rewrote the header to state exactly what
is and is not covered, and that it is per-process rather than fleet-wide.
Skipped authorization guard. Passing `ctx.activeConnection` as `record` bypassed
getConnectionRecord, and with it the only place ConnectionNotAuthorizedError is
raised — which queue.ts classifies as fatal. That context is not guaranteed to
carry tokens: getActiveConnection validates them on the default-connection
branch but its findFirstConnection fallback does not. Take the shortcut only for
a record that would have passed anyway.
Gmail freshness probe. Its catch sat inside withGmailCall, eating errors before
the rate-limit bookkeeping ran, so a 429 on the probe never tripped the
per-mailbox breaker and the caller then spent a second request on the full get.
Moved the swallow outside, and added the probe to IDEMPOTENT_GMAIL_OPERATIONS —
it was the only pure read in the driver getting maxAttempts=1.
Also: the update-error toast classified any message containing 502/503/504 as a
dropped connection, telling users a real server rejection "may have saved".
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 10, 2026, 12:39 PMcrm-sync-href.test.tsfix: address Greptile review findings on Attio workspace-slug resolution
Three issues flagged on PR #2817:
1. Wrong Attio workspace owner (P1): the self-heal fallback resolved the Attio
workspace slug from conversation.userId (the conversation owner), not the user
whose action actually produced the link — a shared conversation's owner may
have no Attio connection, or a different one than whoever linked the deal.
Added ExternalCrmLinkDetails.actingUserId, threaded through from the two
Mastra tool call sites (their agent-run's own userId), and prefer it over
conversation.userId when resolving the slug.
2. Hydration can stall indefinitely (P1): neither the Klavis credential fetch nor
the Attio /v2/self fetch inside the conversation-hydration read path had a
timeout. Bounded the whole self-heal attempt at 5s via Promise.race, plus an
explicit AbortSignal on the /v2/self fetch itself (both the hydration-path
and connect-time call sites).
3. Unchecked assertions (P2): replaced `as string | undefined` / `as {...}` casts
on the /v2/self response and Klavis access_token with runtime narrowing
(typeof checks / a shared extractWorkspaceField helper), and removed three
`as ... as HydratedConversation` / `as any[]` casts that turned out to be
structurally unnecessary once checked against the real target types.
New tests cover the actingUserId resolution order (external-crm-events.test.ts);
existing conversationTool.test.ts assertions updated for the new argument.
Co-Authored-By: Claude Sonnet 5 <<email>>Sep 17, 2026, 10:40 PMfilter-sort-display.test.tsfeat(pipeline): filter the table down to deals that actually need an action
The Suggested Action column could only be sorted, so a rep scanning for work
still had to read past every row with nothing due. Its popover now offers the
same Has/No binary the Tasks column has, mapped to the currentActionHasTasks
filter the backend already supported.
Two fixes fall out of wiring it:
- The built-in "Deal Actions" tab stored its filter as a `before` operator with
a runtime date, a shape the compute path never read — so the tab that says
"show me what I have to do" only sorted. It filters now.
- The due cutoff was end of tomorrow while the cell shows tasks due through end
of today, so a task-type filter could surface rows whose Suggested Action cell
showed no action. Both sides now share currentActionDueCutoff().
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 11:11 PMoverviewEventProjection.test.tsfix(crm): a row's dot timeline froze at the first list page it was born with
The guard that stops a list refetch from emptying an open conversation's
timeline asked only "does this entry already have events?". A conversation
acquires events on its very first list page, so from then on every later list
payload was discarded too — including the ones carrying a meeting or an email
the deal had since gained. The row kept the timeline it was born with until a
detail load or a full rehydration replaced it.
Both sides of a list-over-list write are the overview shape, so there was never
anything richer to protect there. The guard now compares the incoming
projection against the STORED one and holds only in the case it was written
for: overview landing on top of detail.
That comparison needs the store to remember which projection wrote the events
it is holding, which it did not — `metadata.projection` was passed per write
and never kept, and the two payloads are not distinguishable by inspection
(the overview shape is the detail shape minus its sub-objects, which is also
what an empty timeline looks like). Hence `eventsProjection` on the entry. It
follows the events actually stored rather than the payload that arrived, so a
write the guard held still reports 'detail' and the next refetch is held too —
covered by a test that fires three list refetches in a row.
`conversations` is not in the store's `partialize`, so there are no persisted
entries to migrate; an absent marker means a test fixture and reads as
"unknown", which lets fresh data win.
Reported by Greptile on #2669.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 3, 2026, 10:49 AMpendingConversationFieldWrites.test.tsperf(crm): give conversation rows their own event projection
A row draws a dot timeline and three "last <type> at" columns, and nothing
else — but crm.listConversations was building up to seven nested sub-objects
per event through eight LEFT JOINs to feed it. Rows now get five scalars and a
flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw
three times the history for no more bytes: measured on a 50-row page, 547KB
over 641 events before, 538KB over 1,919 now.
That cap was also quietly wrong. "Last meeting" was derived by scanning
whatever events the list happened to ship, so a deal whose last meeting fell
outside its 15 most recent rendered blank — 53 of the 304 conversations with a
meeting, in the org this was measured on — while SORTING by the same field used
an unbounded MAX(occurred_at) and placed it somewhere the column contradicted.
The three columns now read scalars computed over the whole history, from the
same expressions the ORDER BY builder already used.
Splitting the projections needed guards the single shared type had been hiding:
- setConversations takes an explicit `projection`. Both queries write one store
key and replace wholesale, so a list refetch would otherwise empty the
timeline of whatever deal was open — the same bug that previously just shrank
it to 15 (client-data-architecture.md §1.2).
- getEventType/getEventTitle read `eventType` rather than sniffing which
sub-object came back from a join, which equated "this join returned nothing"
with "this is a note" and would have greyed out every dot.
- a dot click opens EventDetailDialog on an id and fetches the full event,
since the row no longer carries one.
Also carries the in-flight pending-conversation-field-writes mask and its
tests: it edits the same setConversations body, so it cannot be split out.
Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PMpick-active-deal.test.tsfeat(crm): make active-deal rule universal + fix lint/test regressions
The "conversation follows its open deal" behavior is correct for every org — if
an open deal belongs to another rep, they should own it in Cedar too — so it is no
longer a per-org rollout gate. Removed CRM_ACTIVE_DEAL_RECONCILE_ENABLED (default-off)
and CRM_ACTIVE_DEAL_RECONCILE_ORG_IDS (per-org allowlist); the rule now runs for all
orgs by default. Retained ONE global emergency kill-switch,
CRM_ACTIVE_DEAL_RECONCILE_DISABLED=true, which restores the pre-rule behavior
everywhere (orphan pruning + field mappings only) without a code revert. Reverted the
Tier 3 org-threading that only existed to feed the per-org gate.
CI fixes:
- oxlint: removed two needless object spreads (attio/copper normalizers) and wired
the previously-unused applyAttioDealState into Attio getAllDeals/getDealsByIds, so
Attio owner extraction is applied like Copper/Salesforce.
- frontend jest: pick-active-deal.test.ts imported from 'vitest' but apps/mail runs
tests under Jest — dropped the import so it uses Jest globals like its siblings.
471 server CRM tests + frontend jest green, tsc clean, oxlint --deny-warnings clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Aug 4, 2026, 9:43 PMstrategic-overview-layout.test.tsfix(overview): the layout editor names the field the deal view actually shows
The editor and the Overview tab each kept their own copy of the top-row field
list, and only the tab rewrote the legacy `next_step_quality` cell to the native
`status` field. So the editor labelled a cell "Next Step Quality" that every deal
renders as "Stage". Both now read one shared definition, which also carries the
rewrite, the `risk` label the tab was missing, and the five-slot cap.
The default row is Stage, Next step, Last touch, Forecast, ACV. The seed's sixth
cell overflowed a five-slot grid; `on_track` and the native `risk` stay extracted
and stay selectable, they are just no longer in the default row.
migrate-strategic-top-row.ts brings the 130 stored rows onto that shape. It
resolves the Forecast cell per-AOP: three AOPs never had the seeded `forecast`
field, only their own CRM's `forecast_category` with real options and ~200 real
values, so they point the cell at the field they actually have rather than at a
blank one.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 11:02 PM