Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,6 @@ function createRuntime() {
lastActivityAt: "2026-03-17T19:00:00.000Z",
createdAt: "2026-03-17T19:00:00.000Z",
})),
getSettlementBlockers: vi.fn(async () => []),
createScheduledWork: vi.fn(async ({ sessionId, cron, runAt, prompt, recurring = true }: {
sessionId: string;
cron?: string;
Expand Down
64 changes: 62 additions & 2 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6633,6 +6633,9 @@ describe("outbound changeset ack retries", () => {
"operations",
"attempt_transcripts",
"sync_cluster_state",
// 39% of the synced project database, for rows the phone re-fetches on
// demand through the required `prs.refresh` action anyway.
"pull_request_snapshots",
]));

peer.ws.send(encodeSyncEnvelope({
Expand Down Expand Up @@ -6669,6 +6672,59 @@ describe("outbound changeset ack retries", () => {
}
});

/**
* `pull_request_snapshots` was 39% of a real 28 MB synced project database
* (258 rows averaging 42 KB, one `files_json` at 1.58 MB) for data the phone
* already pulls on demand. The list rows it renders live must keep flowing.
*/
it("keeps slim PR rows flowing to a phone while withholding the heavy snapshot blobs", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const prRow = (dbVersion: number, table: string, seq: number): CrsqlChangeRow => ({
table,
pk: `pr-${seq}`,
cid: "payload",
val: `payload-${seq}`,
col_version: dbVersion,
db_version: dbVersion,
site_id: "site-host",
cl: 1,
seq,
});
const state = {
dbVersion: 1,
changes: [prRow(1, "pull_requests", 0)],
};
const { host } = createControlledChangesetHost(projectRoot, state);
let peer: Awaited<ReturnType<typeof connectPeer>> | null = null;
try {
const port = await host.waitUntilListening();
// connectPeer defaults to an iOS phone, which is what scopes the filter.
peer = await connectPeer(port, host.getBootstrapToken(), "ios-pr-filter", {
capabilities: ["changesetAck", SYNC_CHUNKED_ENVELOPES_CAPABILITY],
});

state.dbVersion = 2;
state.changes.push(prRow(2, "pull_request_snapshots", 1));
state.changes.push(prRow(2, "pull_requests", 2));

const batch = await waitForValue(
() => peer?.envelopes
.filter((envelope) => envelope.type === "changeset_batch")
.map((envelope) => envelope.payload as SyncChangesetBatchPayload)
.find((payload) => payload.toDbVersion === 2),
"post-snapshot changeset batch",
);

const tables = batch.changes.map((change) => change.table);
expect(tables).toContain("pull_requests");
expect(tables).not.toContain("pull_request_snapshots");
} finally {
peer?.ws.close();
await host.dispose();
cleanup();
}
});

it("retries an abandoned far-behind replica reseed from its old cursor after recovery backoff", async () => {
const { projectRoot, cleanup } = createTempProjectRoot();
const targetDbVersion = SYNC_HOST_MOBILE_REPLICA_RESEED_GAP + 1;
Expand Down Expand Up @@ -10782,19 +10838,23 @@ describe("chat event replay buffer (resumable chat streams)", () => {
};

const compacted = compactChatEventEnvelopeForSync(desktopEnvelope);
// The wire now runs the same compaction the stored transcript does, so the
// redaction notice and the byte accounting are the storage policy's: the
// original size is measured over the pretty-printed serialization the
// compactor works on, not a compact `JSON.stringify`.
expect(compacted.event).toMatchObject({
type: "tool_result",
result: {
output: {
images: [
`[ADE] Inline image data omitted from mobile chat sync (${Buffer.byteLength(largeImage, "utf8")} bytes).`,
`[ADE] Inline image was left out (${Buffer.byteLength(largeImage, "utf8")} bytes).`,
smallImage,
],
message: "generated two previews",
},
count: 2,
},
resultOriginalBytes: Buffer.byteLength(JSON.stringify(result), "utf8"),
resultOriginalBytes: Buffer.byteLength(JSON.stringify(result, null, 2), "utf8"),
resultOmittedBytes: Buffer.byteLength(largeImage, "utf8"),
});
expect(desktopEnvelope.event).toMatchObject({ result });
Expand Down
172 changes: 27 additions & 145 deletions apps/ade-cli/src/services/sync/syncHostService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { Bonjour, type Service as BonjourService } from "bonjour-service";
import { WebSocketServer, WebSocket } from "ws";
import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout";
import { compactChatEventForWire } from "../../../../desktop/src/shared/chatEventCompaction";
import { parseCodedErrorMessage } from "../../../../desktop/src/shared/codedError";
import {
MOBILE_SYNC_COMPATIBILITY_CONTRACT_VERSION,
Expand Down Expand Up @@ -284,6 +285,26 @@ const MOBILE_CHANGESET_EXCLUDED_TABLES = new Set([
"budget_usage_records",
"automation_runs",
"automation_action_results",
// 11.2 MB of a 28.1 MB synced project database (39.7%) — 258 rows averaging
// 43 KB, one `files_json` at 1.65 MB — for data the phone was already fetching a second
// time on its own. iOS reads this table in exactly one SELECT, the per-PR
// detail query behind `fetchPullRequestSnapshot(prId:)`, and it reaches that
// data through `prs.refresh` → `replacePullRequestHydration` on demand.
// `prs.refresh` and `prs.getMobileSnapshot` are both in the REQUIRED remote
// command set, so no paired build — however old — loses PR detail by not
// receiving these rows.
//
// Lists and badges are unaffected: the slim `pull_requests` rows still sync,
// and while four iOS projections name this table as an invalidation trigger,
// no projection query actually reads a column from it.
//
// Devices paired before this keep the rows they already have (nothing deletes
// them), so previously-opened PRs still render offline; they simply stop
// receiving updates through the changeset pump and refresh on open instead.
// This also ends a scroll-driven write path: the desktop Lanes page's
// visible-lane refresh upserts here, so scrolling was pushing changesets to
// every phone.
"pull_request_snapshots",
]);

// Tables the host alone is authoritative for. `sync_cluster_state` is the
Expand Down Expand Up @@ -1675,7 +1696,6 @@ export const TERMINAL_INPUT_RETRY_WINDOW_MS = 60_000;
export const TERMINAL_INPUT_MAX_OUTSTANDING = 64;
export const ACCOUNT_AUTH_TRANSIENT_IDENTITY_GRACE_MS = 5 * 60_000;
export const CONNECTION_ATTEMPT_RESERVATION_TTL_MS = 30_000;
const SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES = 64 * 1024;
// Delivery-key dedupe map cap. Must exceed CHAT_EVENT_REPLAY_MAX_EVENTS so a
// buffered event's key cannot be evicted while the event itself is still in
// the ring buffer (which could double-assign a seq to the same event).
Expand Down Expand Up @@ -1815,155 +1835,17 @@ function normalizeTerminalInputId(value: unknown): string | null {
return inputId;
}

function inlineImageDataUrlBytes(value: string | null | undefined): number | null {
if (!value || !/^data:image\//i.test(value.trim())) return null;
return Buffer.byteLength(value, "utf8");
}

function redactInlineImageDataUrlsForSync(
value: unknown,
): { value: unknown; omittedBytes: number; changed: boolean } {
const seen = new WeakSet<object>();
const visit = (
candidate: unknown,
depth: number,
): { value: unknown; omittedBytes: number; changed: boolean } => {
if (typeof candidate === "string") {
const bytes = inlineImageDataUrlBytes(candidate);
if (bytes == null || bytes <= SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES) {
return { value: candidate, omittedBytes: 0, changed: false };
}
return {
value: `[ADE] Inline image data omitted from mobile chat sync (${bytes} bytes).`,
omittedBytes: bytes,
changed: true,
};
}
if (!candidate || typeof candidate !== "object") {
return { value: candidate, omittedBytes: 0, changed: false };
}
if (depth >= 32) {
return {
value: "[ADE] Deep structured payload omitted from mobile chat sync.",
omittedBytes: 0,
changed: true,
};
}
if (seen.has(candidate)) {
return { value: "[Circular]", omittedBytes: 0, changed: true };
}

seen.add(candidate);
if (Array.isArray(candidate)) {
let omittedBytes = 0;
let changed = false;
const next = candidate.map((entry) => {
const result = visit(entry, depth + 1);
omittedBytes += result.omittedBytes;
changed ||= result.changed;
return result.value;
});
seen.delete(candidate);
return { value: changed ? next : candidate, omittedBytes, changed };
}

let omittedBytes = 0;
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(candidate)) {
const result = visit(entry, depth + 1);
next[key] = result.value;
omittedBytes += result.omittedBytes;
changed ||= result.changed;
}
seen.delete(candidate);
return { value: changed ? next : candidate, omittedBytes, changed };
};

return visit(value, 0);
}

function serializedSyncPayloadBytes(value: unknown): number {
if (typeof value === "string") return Buffer.byteLength(value, "utf8");
try {
const serialized = JSON.stringify(value);
return Buffer.byteLength(serialized ?? String(value), "utf8");
} catch {
return Buffer.byteLength(String(value), "utf8");
}
}

/**
* Bound inline image payloads at the mobile-sync boundary. The agent chat
* service intentionally keeps the original envelope for desktop live
* previews; only WebSocket snapshots/events and their replay ring use this
* compact copy.
* Envelope adapter for the wire. The policy lives in
* `shared/chatEventCompaction` — see its header for why the wire and the stored
* transcript have to share one. Every outbound path (live push, replay ring,
* snapshot backfill) funnels through here.
*/
export function compactChatEventEnvelopeForSync(
envelope: AgentChatEventEnvelope,
): AgentChatEventEnvelope {
const event = envelope.event;
if (event.type === "tool_result") {
const redacted = redactInlineImageDataUrlsForSync(event.result);
if (!redacted.changed) return envelope;
const originalBytes = Math.max(
event.resultOriginalBytes ?? 0,
serializedSyncPayloadBytes(event.result),
redacted.omittedBytes,
);
return {
...envelope,
event: {
...event,
result: redacted.value,
resultOriginalBytes: originalBytes,
resultOmittedBytes: (event.resultOmittedBytes ?? 0) + redacted.omittedBytes,
},
};
}

if (event.type === "codex_image_generation") {
const resultBytes = inlineImageDataUrlBytes(event.result);
const savedPathIsInline = inlineImageDataUrlBytes(event.savedPath) != null;
if (resultBytes != null && resultBytes > SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES) {
return {
...envelope,
event: {
...event,
result: null,
...(savedPathIsInline ? { savedPath: null } : {}),
resultOriginalBytes: resultBytes,
resultOmittedBytes: resultBytes,
},
};
}
if (savedPathIsInline) {
return { ...envelope, event: { ...event, savedPath: null } };
}
return envelope;
}

if (event.type === "codex_image_view") {
const urlBytes = inlineImageDataUrlBytes(event.url);
const pathIsInline = inlineImageDataUrlBytes(event.path) != null;
if (urlBytes != null && urlBytes > SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES) {
return {
...envelope,
event: {
...event,
url: null,
...(pathIsInline ? { path: null } : {}),
urlOriginalBytes: urlBytes,
urlOmittedBytes: urlBytes,
},
};
}
if (pathIsInline) {
return { ...envelope, event: { ...event, path: null } };
}
}

return envelope;
const event = compactChatEventForWire(envelope.event);
return event === envelope.event ? envelope : { ...envelope, event };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export type ChatEventReplayBufferEntry = {
Expand Down
Loading
Loading