From 4b5afbb4316709831a7c6197ab1526a8d420113d Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:45:37 -0700 Subject: [PATCH 1/7] fix(cloud): stop replay upload and retention retry storms Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../Org2Cloud/org2CloudStorageClient.test.ts | 26 ++++++-- .../Org2Cloud/org2CloudStorageClient.ts | 34 ++++++---- .../org2CloudSyncEngine.retentionPark.test.ts | 65 +++++++++++++++++++ src/features/Org2Cloud/org2CloudSyncEngine.ts | 30 +++++++++ 4 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 src/features/Org2Cloud/org2CloudSyncEngine.retentionPark.test.ts diff --git a/src/features/Org2Cloud/org2CloudStorageClient.test.ts b/src/features/Org2Cloud/org2CloudStorageClient.test.ts index 9c9e6dfcc9..d7cc41e31d 100644 --- a/src/features/Org2Cloud/org2CloudStorageClient.test.ts +++ b/src/features/Org2Cloud/org2CloudStorageClient.test.ts @@ -125,14 +125,32 @@ describe("uploadReplayObject", () => { expect((error as Org2CloudStorageError).status).toBe(400); }); - it("accepts a plain 409 duplicate when the object is readable", async () => { - fetchMock - .mockResolvedValueOnce(new Response(null, { status: 409 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); + it("accepts a plain 409 duplicate without a read-back probe", async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 })); + + await expect( + uploadReplayObject("jwt-1", "org-1/s-1/1/1-h.gz", new Uint8Array([1])) + ).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("accepts a 400-wrapped KeyAlreadyExists even when the object is unreadable", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + statusCode: "409", + error: "Duplicate", + message: "The resource already exists", + code: "KeyAlreadyExists", + }), + { status: 400 } + ) + ); await expect( uploadReplayObject("jwt-1", "org-1/s-1/1/1-h.gz", new Uint8Array([1])) ).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/features/Org2Cloud/org2CloudStorageClient.ts b/src/features/Org2Cloud/org2CloudStorageClient.ts index 6f0d6467a9..cea4d22e0b 100644 --- a/src/features/Org2Cloud/org2CloudStorageClient.ts +++ b/src/features/Org2Cloud/org2CloudStorageClient.ts @@ -70,15 +70,17 @@ export async function uploadReplayObject( }); if (response.ok) return; const body = await response.text().catch(() => ""); - // Replay objects are content-addressed (segment hash in the key) and the - // storage policies grant INSERT but never UPDATE, so re-uploading an - // existing name is rejected rather than applied — the normal retry/resume - // path. Supabase reports it as 409, or as a 400 envelope wrapping a 403 - // RLS denial when the policy blocks the implied update, which is - // indistinguishable by status from a genuine authorization failure. - // Confirm the object is actually readable before treating it as done, so - // a real denial still surfaces. - if (mayMeanReplayObjectExists(response.status, body)) { + // Replay objects are content-addressed (segment hash in the key), so a + // duplicate-name rejection means the exact bytes are already stored — + // resume treats it as success without a read-back: the member read path + // walks the session read ladder, which denies replay reads on + // metadata-only shares, so an existence probe cannot confirm objects the + // uploader is not allowed to read and would fail this path forever. + if (isDuplicateObjectRejection(response.status, body)) return; + // A 400/403 RLS denial is ambiguous: the policy blocks the implied + // update on an existing name with the same text as a genuine + // authorization failure. Only here does the read-back decide. + if (mayBeMaskedDuplicate(response.status, body)) { const exists = await replayObjectExists( accessToken, path, @@ -94,17 +96,25 @@ export async function uploadReplayObject( ); } -/** Statuses/bodies that can mean "this object name is already stored". */ -function mayMeanReplayObjectExists(status: number, body: string): boolean { +/** Rejections that unambiguously mean "this object name is already stored". */ +function isDuplicateObjectRejection(status: number, body: string): boolean { if (status === 409) return true; if (status !== 400 && status !== 403) return false; return ( - body.includes("row-level security policy") || body.includes("Duplicate") || + body.includes("KeyAlreadyExists") || body.includes("already exists") ); } +/** RLS denials that may be masking an insert onto an existing name. */ +function mayBeMaskedDuplicate(status: number, body: string): boolean { + return ( + (status === 400 || status === 403) && + body.includes("row-level security policy") + ); +} + /** HEAD probe used only to confirm an upload rejection was a duplicate. */ async function replayObjectExists( accessToken: string, diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.retentionPark.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.retentionPark.test.ts new file mode 100644 index 0000000000..db5f687b11 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncEngine.retentionPark.test.ts @@ -0,0 +1,65 @@ +/** + * Retention parking on the push loop. + * + * A session past the org's retention window fails its push with + * ORG2_RETENTION_EXPIRED on every pass; retention only recedes further + * within a signed-in run, so the engine must stop re-walking the doomed + * upload chain instead of retrying it each pass. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { Org2CloudSyncError } from "./org2CloudSyncClient"; +import { + cleanupEngineFixture, + createEngineFixture, +} from "./org2CloudSyncEngine.testUtils"; +import type { EngineFixture } from "./org2CloudSyncEngine.testUtils"; +import { + getSyncJournalSnapshot, + resetSyncJournalForTests, +} from "./org2CloudSyncJournal"; + +describe("Org2CloudSyncEngine retention parking", () => { + let fixture: EngineFixture; + let engine: EngineFixture["engine"]; + + beforeEach(() => { + resetSyncJournalForTests(); + fixture = createEngineFixture(); + ({ engine } = fixture); + }); + + afterEach(() => { + cleanupEngineFixture(engine); + resetSyncJournalForTests(); + }); + + it("parks a retention-expired session instead of retrying every pass", async () => { + fixture.client.upsertSessionMetadata.mockRejectedValue( + new Org2CloudSyncError("ORG2_RETENTION_EXPIRED", 400) + ); + + await engine.runSyncPass(); + expect(fixture.client.upsertSessionMetadata).toHaveBeenCalledTimes(1); + expect( + getSyncJournalSnapshot().some( + (event) => + event.kind === "session_retention_parked" && + event.code === "ORG2_RETENTION_EXPIRED" + ) + ).toBe(true); + + await engine.runSyncPass(); + expect(fixture.client.upsertSessionMetadata).toHaveBeenCalledTimes(1); + }); + + it("keeps retrying pushes that fail with other codes", async () => { + fixture.client.upsertSessionMetadata.mockRejectedValue( + new Org2CloudSyncError("ORG2_VALIDATION", 400) + ); + + await engine.runSyncPass(); + await engine.runSyncPass(); + expect(fixture.client.upsertSessionMetadata).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.ts b/src/features/Org2Cloud/org2CloudSyncEngine.ts index c1485e5530..87e1ddc3ad 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.ts @@ -147,6 +147,7 @@ import { resolveContinuationStatusesViaCache, resolveLocalSessionIdsViaAggregateList, } from "./org2CloudSyncEngine.vanishedSessions"; +import { recordSyncEvent } from "./org2CloudSyncJournal"; import { type CloudStore, Org2CloudSyncLifecycle, @@ -187,6 +188,12 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { private readonly orgBackoff: Org2CloudOrgBackoffTracker; /** Generation whose background-org retract reconcile already ran (P2). */ private reconciledGeneration = -1; + /** "orgId|sessionId" keys whose push failed with ORG2_RETENTION_EXPIRED. + * Retention only recedes further within a signed-in run, so the push is + * doomed until the org's entitlement changes — parked until the next + * resetSyncState() (sign-in cycle / endpoint switch / app restart) + * instead of re-walking the full upload chain every pass. */ + private readonly retentionParked = new Set(); /** TTL-gated `org2CloudRepoScopesAtom` mirror hydration, split out to * `Org2CloudRepoScopeSync`. */ private readonly repoScopeSync: Org2CloudRepoScopeSync; @@ -324,6 +331,7 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { } protected override resetSyncState(): void { + this.retentionParked.clear(); this.orgBackoff.reset(); this.sessionSync.reset(); this.repoScopeSync.reset(); @@ -483,6 +491,9 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { for (const session of store.get(sessionsAtom)) { if (this.generation !== generation) return; if (!isCloudPushCandidate(session)) continue; + if (this.retentionParked.has(`${org.orgId}|${session.session_id}`)) { + continue; + } // A fork is a continuation inside the source collaboration boundary, // not a new ordinary repo session. Repo scopes may overlap across a // team org and the forker's personal org, so scope matching alone @@ -740,6 +751,25 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { this.orgBackoff.backOffOrg(org.orgId, error); break; // Stop touching this org for the rest of the run. } + if ( + org2CloudSyncClient.isOrg2SyncErrorCode( + error, + "ORG2_RETENTION_EXPIRED" + ) + ) { + this.retentionParked.add(`${org.orgId}|${session.session_id}`); + recordSyncEvent({ + level: "warn", + kind: "session_retention_parked", + orgId: org.orgId, + message: `Push parked for session ${session.session_id}: past the org's retention window`, + code: "ORG2_RETENTION_EXPIRED", + }); + log.warn( + `cloud push parked for retention-expired session ${session.session_id}` + ); + continue; + } log.warn( `cloud push failed for session ${session.session_id}:`, error From 2ded02d5414a74e16e6def28374297437583a960 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:45:55 -0700 Subject: [PATCH 2/7] fix(notifications): keep mobile-only action APIs inert on desktop Pre-commit hook ran. Total eslint: 5, total circular: 0 --- src/api/services/notification.test.ts | 47 +++++---------------------- src/api/services/notification.ts | 39 +++++++--------------- 2 files changed, 20 insertions(+), 66 deletions(-) diff --git a/src/api/services/notification.test.ts b/src/api/services/notification.test.ts index 8539ce04cb..6096a77cfe 100644 --- a/src/api/services/notification.test.ts +++ b/src/api/services/notification.test.ts @@ -154,11 +154,7 @@ describe("notification service", () => { }); }); - it("preserves navigation metadata and disposes native action listeners", async () => { - const unregister = vi.fn(); - const handler = vi.fn(); - mocks.onAction.mockResolvedValueOnce({ unregister }); - + it("preserves navigation metadata on the sent notification", async () => { await sendSystemNotification("Assigned", "Review it", { orgiiTarget: "team-inbox", teamInboxItemKey: "assigned_work_item:WI-1", @@ -174,45 +170,18 @@ describe("notification service", () => { actionTypeId: undefined, autoCancel: true, }); - - const dispose = await listenForSystemNotificationActions(handler); - const nativeHandler = mocks.onAction.mock.calls[0]?.[0] as - | ((notification: { extra?: Record }) => void) - | undefined; - nativeHandler?.({ - extra: { - orgiiTarget: "team-inbox", - teamInboxItemKey: "assigned_work_item:WI-1", - }, - }); - expect(handler).toHaveBeenCalledWith({ - extra: { - orgiiTarget: "team-inbox", - teamInboxItemKey: "assigned_work_item:WI-1", - }, - }); - - dispose(); - expect(unregister).toHaveBeenCalledOnce(); }); - it("registers a foreground View action for Team Inbox notifications", async () => { - mocks.registerActionTypes.mockResolvedValueOnce(undefined); + it("keeps the mobile-only action entry points inert on desktop", async () => { + const handler = vi.fn(); await registerTeamInboxNotificationActionType("View"); + expect(mocks.registerActionTypes).not.toHaveBeenCalled(); - expect(mocks.registerActionTypes).toHaveBeenCalledWith([ - { - id: "orgii-team-inbox", - actions: [ - { - id: "view-team-inbox", - title: "View", - foreground: true, - }, - ], - }, - ]); + const dispose = await listenForSystemNotificationActions(handler); + expect(mocks.onAction).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + expect(() => dispose()).not.toThrow(); }); it("projects positive and cleared dock badge values", async () => { diff --git a/src/api/services/notification.ts b/src/api/services/notification.ts index e12917fa21..1a19d008bc 100644 --- a/src/api/services/notification.ts +++ b/src/api/services/notification.ts @@ -2,8 +2,6 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { isPermissionGranted, - onAction, - registerActionTypes, requestPermission, sendNotification, } from "@tauri-apps/plugin-notification"; @@ -168,35 +166,22 @@ export const sendSystemNotification = async ( } }; -/** Project the authoritative Team Inbox unread count into the dock badge. */ -export const registerTeamInboxNotificationActionType = async ( - viewLabel: string -): Promise => { - await registerActionTypes([ - { - id: TEAM_INBOX_NOTIFICATION_ACTION_TYPE_ID, - actions: [ - { - id: "view-team-inbox", - title: viewLabel, - foreground: true, - }, - ], - }, - ]); -}; - /** - * Listen for native notification activation while the application process is - * alive. The returned disposer is safe to call during React effect cleanup. + * Notification action buttons are a mobile-only concept in the notification + * plugin: its desktop `invoke_handler` registers just notify/permission + * commands, so `registerActionTypes` and the action listener can only fail + * with "Command not found" on every desktop launch. Both entry points are + * kept as inert seams for a future mobile target. */ +export const registerTeamInboxNotificationActionType = async ( + _viewLabel: string +): Promise => {}; + +/** See `registerTeamInboxNotificationActionType` — inert on desktop. */ export const listenForSystemNotificationActions = async ( - handler: (action: SystemNotificationAction) => void + _handler: (action: SystemNotificationAction) => void ): Promise<() => void> => { - const listener = await onAction((notification) => { - handler({ extra: notification.extra ?? {} }); - }); - return () => listener.unregister(); + return () => {}; }; /** From d2051b686f4f2167583ccac77e34aa6a64f4be8d Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:46:12 -0700 Subject: [PATCH 3/7] fix(cloud): keep cloud auth fresh via token callbacks Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../Org2Cloud/org2CloudRealtimeClient.test.ts | 45 ++++++++++--------- .../Org2Cloud/org2CloudRealtimeClient.ts | 39 +++++++++++----- .../Org2Cloud/teamInboxMentionsClient.ts | 22 +++++++-- .../Org2Cloud/useOrg2CloudRealtime.ts | 26 +++++++---- 4 files changed, 89 insertions(+), 43 deletions(-) diff --git a/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts b/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts index afa275c61e..49f88f173d 100644 --- a/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts +++ b/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts @@ -65,7 +65,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); it("opens the presence/broadcast channel as private with the presence key", () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -82,7 +82,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); it("surfaces presence-channel subscription edges through onStatus", () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const edges: boolean[] = []; conn.joinPresence({ scope: "org:org-123", @@ -99,20 +99,25 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { expect(edges).toEqual([true, false, true, false]); }); - it("authorizes the socket with the access token before joining (RLS private-channel requirement)", () => { - createOrg2CloudRealtimeConnection("token-abc"); - expect(setAuthMock).toHaveBeenCalledWith("token-abc"); + it("wires the token callback into the client and arms callback-based auth", () => { + const getToken = async () => "token-abc"; + createOrg2CloudRealtimeConnection(getToken); + const options = vi.mocked(createClient).mock.calls.at(-1)?.[2] as { + accessToken?: () => Promise; + }; + expect(options.accessToken).toBe(getToken); + expect(setAuthMock).toHaveBeenCalledWith(); }); - it("re-authorizes the live socket when the token is refreshed", () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + it("re-resolves the callback token when nudged after a rotation", () => { + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); setAuthMock.mockClear(); - conn.setAuth("token-def"); - expect(setAuthMock).toHaveBeenCalledWith("token-def"); + conn.setAuth(); + expect(setAuthMock).toHaveBeenCalledWith(); }); it("leaves table-change channels public (postgres_changes are gated by table RLS, not realtime.messages)", () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); conn.subscribe({ table: "org_memberships", filter: "org_id=eq.org-123", @@ -127,7 +132,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); it("uses a fresh topic for a fast same-filter resubscribe", () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const options = { table: "org_memberships", filter: "org_id=eq.org-123", @@ -150,7 +155,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { const initialTrack = new Promise((resolve) => { releaseInitialTrack = () => resolve("ok"); }); - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -177,7 +182,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); it("does not track before a view and publishes an explicit idle view on close", async () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -203,7 +208,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); it("queues broadcasts sent while the private channel is reconnecting", async () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -226,7 +231,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { it("retries a broadcast transport failure without losing its nudge", async () => { vi.useFakeTimers(); - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -248,7 +253,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { it("backs off persistently failing broadcasts instead of retrying at 1 Hz", async () => { vi.useFakeTimers(); - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -289,7 +294,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { it("resets the broadcast backoff once a send succeeds", async () => { vi.useFakeTimers(); - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -321,7 +326,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { it("backs off persistently failing presence tracks instead of retrying at 1 Hz", async () => { vi.useFakeTimers(); - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -345,7 +350,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); it("does not let a timed-out track block a newer presence payload", async () => { - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const handle = conn.joinPresence({ scope: "org:org-123", key: "user-9", @@ -367,7 +372,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { it("shares the five-call rolling Presence budget across org channels", async () => { vi.useFakeTimers(); - const conn = createOrg2CloudRealtimeConnection("token-abc"); + const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); for (let index = 0; index < 6; index += 1) { conn.joinPresence({ scope: `org:org-${index}`, diff --git a/src/features/Org2Cloud/org2CloudRealtimeClient.ts b/src/features/Org2Cloud/org2CloudRealtimeClient.ts index bb693998a5..dd1b4ae824 100644 --- a/src/features/Org2Cloud/org2CloudRealtimeClient.ts +++ b/src/features/Org2Cloud/org2CloudRealtimeClient.ts @@ -123,26 +123,35 @@ export interface Org2CloudRealtimeConnection { subscribe(options: Org2CloudSubscribeOptions): () => void; /** Join a Presence channel (ephemeral who-is-here state; never touches Postgres). */ joinPresence(options: Org2CloudPresenceOptions): Org2CloudPresenceHandle; - /** Push a refreshed access token to the live socket (RLS re-auth). */ - setAuth(accessToken: string): void; + /** Re-run the token callback and push the result to the live socket. */ + setAuth(): void; /** Tear down all channels and the socket. */ dispose(): void; } /** - * Build a Realtime connection for the CURRENT endpoint, authenticated as the - * given user. The client is realtime-only: auth auto-refresh and session - * persistence are disabled (the app owns tokens via `org2CloudAuthAtom`), so - * this never competes with the existing auth machinery. + * Build a Realtime connection for the CURRENT endpoint, authenticated + * through `getAccessToken`. The token is a CALLBACK, not a snapshot: the + * socket re-runs it on every heartbeat (~25s) and channel (re)join, so a + * JWT expiry can never strand the connection. A manually pushed token + * would disable exactly that heartbeat refresh path + * (`_manuallySetToken` in realtime-js), which silently killed + * postgres_changes delivery — and with it every steady-state inbound + * pull — one hour into any session that had no other reason to rotate + * the auth atom. The client is realtime-only: auth auto-refresh and + * session persistence are disabled (the app owns tokens via + * `org2CloudAuthAtom`), so this never competes with the existing auth + * machinery. */ export function createOrg2CloudRealtimeConnection( - accessToken: string + getAccessToken: () => Promise ): Org2CloudRealtimeConnection { const endpoint = getCloudEndpoint(); const client: SupabaseClient = createClient( endpoint.supabaseUrl, endpoint.anonKey, { + accessToken: getAccessToken, auth: { persistSession: false, autoRefreshToken: false, @@ -160,7 +169,10 @@ export function createOrg2CloudRealtimeConnection( }, } ); - client.realtime.setAuth(accessToken); + // Argument-less: resolves through the callback and clears any manual-token + // flag a constructor-time seed may have set, keeping the heartbeat refresh + // path armed. + void client.realtime.setAuth(); const channels = new Set(); let disposed = false; @@ -239,7 +251,12 @@ export function createOrg2CloudRealtimeConnection( let wasEverTimedOut = false; channel.subscribe((status) => { const subscribed = status === "SUBSCRIBED"; - if (!subscribed && status !== "CLOSED") { + if (!subscribed && status === "CLOSED") { + // A closed channel never rejoins on its own (phoenix `joinedOnce`), + // so an unexpected server-side close is exactly the silent-death + // shape worth finding in the console later. + log.warn(`realtime channel ${channelName} closed`); + } else if (!subscribed) { wasEverTimedOut = true; log.warn(`realtime channel ${channelName} status: ${status}`); } else if (subscribed && wasEverTimedOut) { @@ -539,9 +556,9 @@ export function createOrg2CloudRealtimeConnection( }; }; - const setAuth = (token: string): void => { + const setAuth = (): void => { if (disposed) return; - client.realtime.setAuth(token); + void client.realtime.setAuth(); }; const dispose = (): void => { diff --git a/src/features/Org2Cloud/teamInboxMentionsClient.ts b/src/features/Org2Cloud/teamInboxMentionsClient.ts index 6000a1a69e..673d137091 100644 --- a/src/features/Org2Cloud/teamInboxMentionsClient.ts +++ b/src/features/Org2Cloud/teamInboxMentionsClient.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { createLogger } from "@src/hooks/logger"; +import { getFreshCloudAccessToken } from "./cloudShortId"; import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; import { getCloudCapabilities } from "./org2CloudCapabilities"; import { Org2CloudCommentError } from "./org2CloudCommentsClient"; @@ -118,6 +119,19 @@ export interface TeamInboxReadMutation { unreadCount: number; } +/** + * Callers hold the persisted token, which can be expired at cold start — + * resolve freshness centrally so a stale JWT refreshes instead of 401ing. + * Outside a running app store (tests, teardown) the passed token stands. + */ +async function freshestToken(accessToken: string): Promise { + try { + return (await getFreshCloudAccessToken()) ?? accessToken; + } catch { + return accessToken; + } +} + async function callTeamInboxRpc( functionName: string, accessToken: string, @@ -125,6 +139,7 @@ async function callTeamInboxRpc( sourceSignal?: AbortSignal ): Promise { const endpoint = getCloudEndpoint(); + const token = await freshestToken(accessToken); return runCloudRequestWithTimeout( async (signal) => { const response = await fetchWithTransportRetry( @@ -133,7 +148,7 @@ async function callTeamInboxRpc( method: "POST", headers: { apikey: endpoint.anonKey, - authorization: `Bearer ${accessToken}`, + authorization: `Bearer ${token}`, "content-type": "application/json", "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, }, @@ -207,11 +222,12 @@ export async function listInitialTeamInboxMentions( limit = 50, signal?: AbortSignal ): Promise { - const capabilities = await getCloudCapabilities(accessToken); + const token = await freshestToken(accessToken); + const capabilities = await getCloudCapabilities(token); if (!capabilities.teamInboxMentions) { return EMPTY_TEAM_INBOX_MENTIONS_PAGE; } - return listTeamInboxMentions(accessToken, orgId, null, limit, signal); + return listTeamInboxMentions(token, orgId, null, limit, signal); } /** Persists one viewer-scoped mention receipt. The viewer comes from JWT. */ diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts index df687217a7..99ab15befc 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts @@ -57,6 +57,7 @@ import { org2CloudChannelMessagesVersionAtom, org2CloudChannelsVersionAtom, } from "./channels/channelsAtom"; +import { getFreshCloudAccessToken } from "./cloudShortId"; import { org2CloudSharingFloorAtom } from "./org2CloudAccessSettings"; import { commitRefreshedAuth, @@ -360,18 +361,22 @@ export function useOrg2CloudRealtime(): void { setBroadcastSignals(false); const current = authRef.current; if (!userId || !current) return undefined; - void getCloudCapabilities( - current.accessToken, - endpointForOrigin(current.supabaseUrl) - ).then((capabilities) => { + void (async () => { + const fresh = await ensureFreshSession(current); + if (!fresh || cancelled) return; + commitRefreshedAuth(setAuth, current, fresh); + const capabilities = await getCloudCapabilities( + fresh.accessToken, + endpointForOrigin(fresh.supabaseUrl) + ); if (!cancelled && capabilities.broadcastSignals) { setBroadcastSignals(true); } - }); + })(); return () => { cancelled = true; }; - }, [userId, endpointUrl]); + }, [userId, endpointUrl, setAuth]); // `org_change_signals` also carries rare sharing-floor changes. Refresh only // the affected org's entitlement through the shared coordinator @@ -403,7 +408,9 @@ export function useOrg2CloudRealtime(): void { if (!userId || !current || !activeRealtimeOrgId) { return undefined; } - const connection = createOrg2CloudRealtimeConnection(current.accessToken); + const connection = createOrg2CloudRealtimeConnection( + getFreshCloudAccessToken + ); connectionRef.current = connection; // Slice A: the signed-in user's OWN membership rows. Filtering by user_id @@ -446,10 +453,11 @@ export function useOrg2CloudRealtime(): void { // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId, endpointUrl, activeRealtimeOrgId]); - // --- Keep the socket's auth token fresh without rebuilding the connection. + // --- Nudge the socket to re-resolve its token as soon as the atom + // rotates; the heartbeat-driven callback refresh covers the steady state. useEffect(() => { if (auth?.accessToken) { - connectionRef.current?.setAuth(auth.accessToken); + connectionRef.current?.setAuth(); } }, [auth?.accessToken]); From 83dcd8a29907c6edd28b120b0b4acff5d00262a6 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:46:28 -0700 Subject: [PATCH 4/7] fix(work-items): skip cross-org short-id collisions Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../src/projects/io/work_items/crud.rs | 21 ++++++++++++++- .../src/projects/io/work_items/crud_tests.rs | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs index 6abea21bb5..c7002a53d4 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud.rs @@ -810,7 +810,26 @@ pub(crate) fn allocate_short_id_in_tx( } } - let short_id = format!("{}-{:04}", prefix, next_id); + // `workitems.id` is a GLOBAL primary key (`id = short_id` until the id + // migration), while the counter above is per-org: another org sharing + // the prefix may already own the candidate. Walk past global collisions + // so creation never trips the work.create existence guard. + let short_id = loop { + let candidate = format!("{}-{:04}", prefix, next_id); + let taken: bool = map_db( + tx.query_row( + "SELECT 1 FROM workitems WHERE id = ?1", + params![&candidate], + |_| Ok(true), + ) + .optional(), + )? + .unwrap_or(false); + if !taken { + break candidate; + } + next_id = next_id.saturating_add(1); + }; let bumped = next_id.saturating_add(1); map_db(tx.execute( diff --git a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs index ec6c671312..a0f023bbbe 100644 --- a/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs +++ b/src-tauri/crates/project-management/src/projects/io/work_items/crud_tests.rs @@ -462,6 +462,33 @@ fn allocate_short_id_skips_same_prefix_across_projects() { assert_eq!(read_all_work_items("beta").expect("beta items").len(), 1); } +#[test] +fn allocate_short_id_skips_same_prefix_across_orgs() { + let _sandbox = test_env::sandbox(); + seed_project("alpha", "pa"); + let alpha_id = allocate_short_id("alpha").expect("alloc alpha"); + assert_eq!(alpha_id, "AAA-0001"); + let alpha_fm = work_item_fixture(&alpha_id, &alpha_id, "Alpha task"); + write_work_item("alpha", &alpha_id, &alpha_fm, "").expect("write alpha"); + + crate::projects::io::create_project_org(&crate::projects::types::CreateProjectOrgRequest { + name: "Other Org".to_string(), + id: Some("other-org".to_string()), + }) + .expect("create org"); + let mut beta = project_fixture("pb", "beta", "Beta"); + beta.org_id = "other-org".to_string(); + write_project("beta", &beta, "", true).expect("seed beta"); + + let beta_id = allocate_short_id("beta").expect("alloc beta"); + assert_eq!( + beta_id, "AAA-0002", + "workitems.id is global, so shared prefixes must not collide across orgs" + ); + let beta_fm = work_item_fixture(&beta_id, &beta_id, "Beta task"); + write_work_item("beta", &beta_id, &beta_fm, "").expect("write beta"); +} + #[test] fn allocate_short_id_unknown_project_errors() { let _sandbox = test_env::sandbox(); From 9b326c4f0ba55a5fe862e7c3797cc8191981d7a6 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:47:03 -0700 Subject: [PATCH 5/7] fix(collab): quiet stale-path probes and gate channel joins Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../Org2Cloud/org2CloudRealtimeClient.test.ts | 31 ++++- .../Org2Cloud/org2CloudRealtimeClient.ts | 109 +++++++++++------- .../repoScopeResolver.test.ts | 43 ++++++- .../TeamCollaboration/repoScopeResolver.ts | 41 +++++++ 4 files changed, 172 insertions(+), 52 deletions(-) diff --git a/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts b/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts index 49f88f173d..1885b1ee28 100644 --- a/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts +++ b/src/features/Org2Cloud/org2CloudRealtimeClient.test.ts @@ -51,6 +51,12 @@ vi.mock("@supabase/supabase-js", () => ({ })), })); +const flushJoins = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + describe("createOrg2CloudRealtimeConnection presence privacy", () => { beforeEach(() => { channelCalls.length = 0; @@ -64,7 +70,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { vi.clearAllMocks(); }); - it("opens the presence/broadcast channel as private with the presence key", () => { + it("opens the presence/broadcast channel as private with the presence key", async () => { const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); conn.joinPresence({ scope: "org:org-123", @@ -81,7 +87,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); }); - it("surfaces presence-channel subscription edges through onStatus", () => { + it("surfaces presence-channel subscription edges through onStatus", async () => { const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const edges: boolean[] = []; conn.joinPresence({ @@ -92,14 +98,18 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { onStatus: (subscribed) => edges.push(subscribed), }); const channel = createdChannels.at(-1); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); + await flushJoins(); channel?.emitStatus("CHANNEL_ERROR"); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); + await flushJoins(); channel?.emitStatus("CLOSED"); expect(edges).toEqual([true, false, true, false]); }); - it("wires the token callback into the client and arms callback-based auth", () => { + it("wires the token callback into the client and arms callback-based auth", async () => { const getToken = async () => "token-abc"; createOrg2CloudRealtimeConnection(getToken); const options = vi.mocked(createClient).mock.calls.at(-1)?.[2] as { @@ -109,14 +119,14 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { expect(setAuthMock).toHaveBeenCalledWith(); }); - it("re-resolves the callback token when nudged after a rotation", () => { + it("re-resolves the callback token when nudged after a rotation", async () => { const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); setAuthMock.mockClear(); conn.setAuth(); expect(setAuthMock).toHaveBeenCalledWith(); }); - it("leaves table-change channels public (postgres_changes are gated by table RLS, not realtime.messages)", () => { + it("leaves table-change channels public (postgres_changes are gated by table RLS, not realtime.messages)", async () => { const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); conn.subscribe({ table: "org_memberships", @@ -131,7 +141,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { expect(call?.opts).toBeUndefined(); }); - it("uses a fresh topic for a fast same-filter resubscribe", () => { + it("uses a fresh topic for a fast same-filter resubscribe", async () => { const conn = createOrg2CloudRealtimeConnection(async () => "token-abc"); const options = { table: "org_memberships", @@ -166,6 +176,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { expect(channel).toBeDefined(); channel?.track.mockImplementationOnce(() => initialTrack); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); await Promise.resolve(); handle.update({ viewingSessionId: null, updatedAt: 2 }); @@ -191,6 +202,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); const channel = createdChannels.at(-1); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); await Promise.resolve(); expect(channel?.track).not.toHaveBeenCalled(); @@ -220,6 +232,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { handle.send("comments-changed", { sessionId: "session-1" }); expect(channel?.send).not.toHaveBeenCalled(); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); await vi.waitFor(() => expect(channel?.send).toHaveBeenCalledTimes(1)); expect(channel?.send).toHaveBeenCalledWith({ @@ -240,6 +253,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); const channel = createdChannels.at(-1); channel?.send.mockResolvedValueOnce("timed out"); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); handle.send("comments-changed", { sessionId: "session-1" }); @@ -262,6 +276,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); const channel = createdChannels.at(-1); channel?.send.mockResolvedValue("timed out"); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); handle.send("comments-changed", { sessionId: "session-1" }); @@ -306,6 +321,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { .mockResolvedValueOnce("timed out") .mockResolvedValueOnce("timed out") .mockResolvedValueOnce("ok"); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); handle.send("comments-changed", { sessionId: "session-1" }); @@ -335,6 +351,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); const channel = createdChannels.at(-1); channel?.track.mockResolvedValue("timed out"); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); await vi.advanceTimersByTimeAsync(0); @@ -360,6 +377,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { const channel = createdChannels.at(-1); channel?.track.mockResolvedValueOnce("timed out"); + await flushJoins(); channel?.emitStatus("SUBSCRIBED"); handle.update({ viewingSessionId: "session-1", updatedAt: 2 }); @@ -382,6 +400,7 @@ describe("createOrg2CloudRealtimeConnection presence privacy", () => { }); } const sixChannels = createdChannels.slice(-6); + await flushJoins(); for (const channel of sixChannels) channel.emitStatus("SUBSCRIBED"); await vi.advanceTimersByTimeAsync(0); diff --git a/src/features/Org2Cloud/org2CloudRealtimeClient.ts b/src/features/Org2Cloud/org2CloudRealtimeClient.ts index dd1b4ae824..5a8ea4dd37 100644 --- a/src/features/Org2Cloud/org2CloudRealtimeClient.ts +++ b/src/features/Org2Cloud/org2CloudRealtimeClient.ts @@ -171,8 +171,12 @@ export function createOrg2CloudRealtimeConnection( ); // Argument-less: resolves through the callback and clears any manual-token // flag a constructor-time seed may have set, keeping the heartbeat refresh - // path armed. - void client.realtime.setAuth(); + // path armed. Channel joins gate on this settling: the callback is async, + // and a join that races ahead of it goes out without a JWT — private + // channels (presence) then fail authorization outright instead of joining. + const authReady = Promise.resolve(client.realtime.setAuth()).catch( + () => undefined + ); const channels = new Set(); let disposed = false; @@ -249,26 +253,36 @@ export function createOrg2CloudRealtimeConnection( } ); let wasEverTimedOut = false; - channel.subscribe((status) => { - const subscribed = status === "SUBSCRIBED"; - if (!subscribed && status === "CLOSED") { - // A closed channel never rejoins on its own (phoenix `joinedOnce`), - // so an unexpected server-side close is exactly the silent-death - // shape worth finding in the console later. - log.warn(`realtime channel ${channelName} closed`); - } else if (!subscribed) { - wasEverTimedOut = true; - log.warn(`realtime channel ${channelName} status: ${status}`); - } else if (subscribed && wasEverTimedOut) { - // Supabase rejoins with exponential backoff after TIMED_OUT / - // CHANNEL_ERROR; log the recovery so a transient blip is - // distinguishable from a persistent failure in the console. - log.info(`realtime channel ${channelName} recovered (SUBSCRIBED)`); - } - onStatus?.(subscribed); + let intentionallyRemoved = false; + const joinChannel = () => + channel.subscribe((status) => { + const subscribed = status === "SUBSCRIBED"; + if (!subscribed && status === "CLOSED") { + // A closed channel never rejoins on its own (phoenix `joinedOnce`), + // so an UNEXPECTED server-side close is exactly the silent-death + // shape worth finding in the console later — but our own + // unsubscribe/teardown also lands here and is routine. + if (!intentionallyRemoved && !disposed) { + log.warn(`realtime channel ${channelName} closed`); + } + } else if (!subscribed) { + wasEverTimedOut = true; + log.warn(`realtime channel ${channelName} status: ${status}`); + } else if (subscribed && wasEverTimedOut) { + // Supabase rejoins with exponential backoff after TIMED_OUT / + // CHANNEL_ERROR; log the recovery so a transient blip is + // distinguishable from a persistent failure in the console. + log.info(`realtime channel ${channelName} recovered (SUBSCRIBED)`); + } + onStatus?.(subscribed); + }); + void authReady.then(() => { + if (disposed || intentionallyRemoved) return; + joinChannel(); }); channels.add(channel); return () => { + intentionallyRemoved = true; channels.delete(channel); void client.removeChannel(channel); }; @@ -486,32 +500,38 @@ export function createOrg2CloudRealtimeConnection( }); } let wasEverTimedOut = false; - channel.subscribe((status) => { - if (status === "SUBSCRIBED") { - subscribed = true; - published = false; - // A fresh SUBSCRIBED edge means the transport recovered; retry fast - // again instead of inheriting the previous failure streak's ceiling. - trackFailureStreak = 0; - broadcastFailureStreak = 0; - if (wasEverTimedOut) { - log.info(`presence channel ${scope} recovered (SUBSCRIBED)`); + let intentionallyLeft = false; + const joinChannel = () => + channel.subscribe((status) => { + if (status === "SUBSCRIBED") { + subscribed = true; + published = false; + // A fresh SUBSCRIBED edge means the transport recovered; retry fast + // again instead of inheriting the previous failure streak's ceiling. + trackFailureStreak = 0; + broadcastFailureStreak = 0; + if (wasEverTimedOut) { + log.info(`presence channel ${scope} recovered (SUBSCRIBED)`); + } + // A reconnect has no server-side meta even if the local version was + // previously applied, so force the latest payload onto the channel. + desiredTrackVersion += 1; + void flushLatestPayload(); + void flushPendingBroadcasts(); + } else if (status !== "CLOSED") { + subscribed = false; + published = false; + wasEverTimedOut = true; + log.warn(`presence channel ${scope} status: ${status}`); + } else { + subscribed = false; + published = false; } - // A reconnect has no server-side meta even if the local version was - // previously applied, so force the latest payload onto the channel. - desiredTrackVersion += 1; - void flushLatestPayload(); - void flushPendingBroadcasts(); - } else if (status !== "CLOSED") { - subscribed = false; - published = false; - wasEverTimedOut = true; - log.warn(`presence channel ${scope} status: ${status}`); - } else { - subscribed = false; - published = false; - } - onStatus?.(status === "SUBSCRIBED"); + onStatus?.(status === "SUBSCRIBED"); + }); + void authReady.then(() => { + if (disposed || intentionallyLeft) return; + joinChannel(); }); channels.add(channel); return { @@ -537,6 +557,7 @@ export function createOrg2CloudRealtimeConnection( void flushPendingBroadcasts(); }, leave: () => { + intentionallyLeft = true; subscribed = false; published = false; if (retryTimer !== null) { diff --git a/src/features/TeamCollaboration/repoScopeResolver.test.ts b/src/features/TeamCollaboration/repoScopeResolver.test.ts index 5fa68cedfb..5db0dc9393 100644 --- a/src/features/TeamCollaboration/repoScopeResolver.test.ts +++ b/src/features/TeamCollaboration/repoScopeResolver.test.ts @@ -17,6 +17,10 @@ import { subscribeShareableScopeKeys, } from "./repoScopeResolver"; +vi.mock("@tauri-apps/plugin-fs", () => ({ + exists: async (path: string) => existsBehavior(path), +})); +let existsBehavior: (path: string) => boolean = () => true; vi.mock("@src/api/http/git/remotes", () => ({ getGitRemotes: vi.fn(), })); @@ -28,10 +32,43 @@ const remotesMock = vi.mocked(getGitRemotes); const networkIdentityMock = vi.mocked(resolveGitHubRepoNetworkIdentityLocal); beforeEach(() => { + existsBehavior = () => true; networkIdentityMock.mockRejectedValue(new Error("not configured")); clearShareableScopeKeyCache(); }); +describe("git marker pre-check", () => { + beforeEach(() => { + remotesMock.mockReset(); + }); + + it("still probes when the .git marker sits above a package subfolder", async () => { + existsBehavior = (path) => + path === "/repo/packages/app" || path === "/repo/.git"; + remotesMock.mockResolvedValue({ + remotes: [remoteEntry("origin", "git@github.com:acme/mono.git")], + }); + await expect(resolveShareableScopeKey("/repo/packages/app")).resolves.toBe( + "github.com/acme/mono" + ); + expect(remotesMock).toHaveBeenCalledTimes(1); + }); + + it("resolves not-shareable without probing when no checkout exists above", async () => { + existsBehavior = (path) => path === "/plain/folder"; + await expect(resolveShareableScopeKey("/plain/folder")).resolves.toBeNull(); + expect(remotesMock).not.toHaveBeenCalled(); + }); + + it("resolves not-shareable without probing for a deleted path", async () => { + existsBehavior = () => false; + await expect( + resolveShareableScopeKey("/gone/worktree") + ).resolves.toBeNull(); + expect(remotesMock).not.toHaveBeenCalled(); + }); +}); + function remoteEntry(name: string, url: string) { return { name, url, fetch_url: url, push_url: url }; } @@ -131,8 +168,10 @@ describe("resolveShareableScopeKey (git-remote-only sharing, design §8.3)", () const first = resolveShareableScopeKey("/repo/alpha"); const second = resolveShareableScopeKey("/repo/alpha"); expect(peekShareableScopeKey("/repo/alpha")).toBeUndefined(); - // Flush the deferred lookup body — exactly ONE IPC call fires for the - // two concurrent callers. + // Flush the deferred lookup body (including the existence pre-check) — + // exactly ONE IPC call fires for the two concurrent callers. + await Promise.resolve(); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(remotesMock).toHaveBeenCalledTimes(1); diff --git a/src/features/TeamCollaboration/repoScopeResolver.ts b/src/features/TeamCollaboration/repoScopeResolver.ts index 64c697da2a..fc3636b0f4 100644 --- a/src/features/TeamCollaboration/repoScopeResolver.ts +++ b/src/features/TeamCollaboration/repoScopeResolver.ts @@ -18,6 +18,7 @@ * NOT cached, so a repo is never permanently marked unshareable by a hiccup * (e.g. the git server still booting). */ +import { exists } from "@tauri-apps/plugin-fs"; import { useSyncExternalStore } from "react"; import { getGitRemotes } from "@src/api/http/git/remotes"; @@ -209,6 +210,28 @@ export function shareableScopeKeysFromRemoteUrls( return keys.length > 0 ? keys : null; } +type GitMarkerVerdict = "present" | "absent" | "unknown"; + +async function probeGitMarker(path: string): Promise { + try { + if (!(await exists(path))) return "absent"; + } catch { + return "unknown"; + } + let dir = path; + for (let depth = 0; depth < 32; depth += 1) { + try { + if (await exists(`${dir}/.git`)) return "present"; + } catch { + return "unknown"; + } + const cut = Math.max(dir.lastIndexOf("/"), dir.lastIndexOf("\\")); + if (cut <= 0) return "absent"; + dir = dir.slice(0, cut); + } + return "unknown"; +} + /** * The git-remote-only resolver (design §8.3): returns the normalized keys of * ALL remotes (origin first) when the repo has any, and `null` when it does @@ -238,6 +261,24 @@ export async function resolveShareableScopeKeys( // against `task` itself without tripping TS2454 (used before assigned). const task: Promise = Promise.resolve().then( async (): Promise => { + // A deleted path — or a folder with no checkout anywhere above it — + // can never yield remotes: cache the definitive null instead of + // probing, so boot does not spray the git server (and the console's + // network log) with 404s for every stale path a historical session + // still references. The server resolves repos with + // `Repository::discover` (a workspace may be a package/subfolder of + // the checkout), so the `.git` marker is searched UPWARD the same + // way; the marker is a file for worktrees. `exists()` throws for + // paths outside the fs plugin's scope — treated as unknowable, the + // probe decides. + const marker = await probeGitMarker(normalizedInput); + if (marker === "absent") { + if (shareableScopeKeyInFlight.get(normalizedInput) === task) { + writeLruEntry(shareableScopeKeyCache, normalizedInput, null); + notifyShareableScopeKeys(normalizedInput, null); + } + return null; + } const data = await getGitRemotes({ repo_id: normalizedInput, repo_path: normalizedInput, From 17d53258cb15a0b2294bad95458be903a61766e6 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:47:19 -0700 Subject: [PATCH 6/7] fix(cloud): visit tagged orgs on the session push pass Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../org2CloudSyncEngine.sessions.test.ts | 12 ++-- ...org2CloudSyncEngine.taggedOrgVisit.test.ts | 71 +++++++++++++++++++ src/features/Org2Cloud/org2CloudSyncEngine.ts | 10 ++- 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 src/features/Org2Cloud/org2CloudSyncEngine.taggedOrgVisit.test.ts diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts index 6a3bf408ca..28f41c86a4 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.sessions.test.ts @@ -1888,7 +1888,7 @@ describe("Org2CloudSyncEngine session publishing", () => { }); }); - it("publishes a multi-tagged session only to the active org", async () => { + it("publishes a multi-tagged session to every tagged org", async () => { store.set(org2CloudOrgsAtom, [ { orgId: "corg-1", name: "Cloud Team", role: "member" }, { orgId: "corg-2", name: "Other Team", role: "member" }, @@ -1917,9 +1917,13 @@ describe("Org2CloudSyncEngine session publishing", () => { await engine.runSyncPass(); - expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(1); - expect(client.rewriteSessionEvents.mock.calls[0][1].orgId).toBe("corg-1"); - expect(eventStoreMock.getPersistedEvents).toHaveBeenCalledTimes(1); + // A tag is an explicit publish request: an inactive tagged org must not + // wait for the owner to activate it (Move to Org would otherwise report + // success while the target org was never visited). + expect(client.rewriteSessionEvents).toHaveBeenCalledTimes(2); + expect( + client.rewriteSessionEvents.mock.calls.map((call) => call[1].orgId).sort() + ).toEqual(["corg-1", "corg-2"]); }); // --- deleteSession resurrection-hash fix ---------------------------------- diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.taggedOrgVisit.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.taggedOrgVisit.test.ts new file mode 100644 index 0000000000..c20aa311f0 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncEngine.taggedOrgVisit.test.ts @@ -0,0 +1,71 @@ +/** + * Tagged-org visiting on the session push pass. + * + * Move to Org tags a session into an org the user may not be looking at. + * The pass must visit such an org anyway — filtering it out at the + * sessionPushOrgs stage made the dialog's awaited pass complete without + * touching the target org, report success, and leave the session invisible + * to every other member (and its later updates unsynced) until the owner + * happened to activate that org. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { sessionOrgTagsAtom } from "../TeamCollaboration/sessionOrgTagsAtom"; +import { org2CloudOrgsAtom } from "./org2CloudOrgsAtom"; +import { org2CloudRepoScopesAtom } from "./org2CloudSyncAtoms"; +import { + cleanupEngineFixture, + createEngineFixture, +} from "./org2CloudSyncEngine.testUtils"; +import type { EngineFixture } from "./org2CloudSyncEngine.testUtils"; + +describe("Org2CloudSyncEngine tagged-org visiting", () => { + let fixture: EngineFixture; + let engine: EngineFixture["engine"]; + + beforeEach(() => { + fixture = createEngineFixture(); + ({ engine } = fixture); + }); + + afterEach(() => { + cleanupEngineFixture(engine); + }); + + it("pushes a tagged session to an inactive org without background upload", async () => { + const { store, client } = fixture; + store.set(org2CloudOrgsAtom, [ + { orgId: "corg-1", name: "Cloud Team", role: "member" }, + { orgId: "corg-2", name: "Other Team", role: "member" }, + ]); + store.set(org2CloudRepoScopesAtom, (current) => ({ + ...current, + "corg-2": current["corg-1"] ?? [], + })); + store.set(sessionOrgTagsAtom, { + "session-1": ["cloud:corg-2"], + }); + + await engine.runSyncPass(); + + const orgsUpserted = client.upsertSessionMetadata.mock.calls.map( + (call) => call[1] + ); + expect(orgsUpserted).toContain("corg-2"); + }); + + it("still skips untagged inactive orgs without background upload", async () => { + const { store, client } = fixture; + store.set(org2CloudOrgsAtom, [ + { orgId: "corg-1", name: "Cloud Team", role: "member" }, + { orgId: "corg-2", name: "Other Team", role: "member" }, + ]); + + await engine.runSyncPass(); + + const orgsUpserted = client.upsertSessionMetadata.mock.calls.map( + (call) => call[1] + ); + expect(orgsUpserted).not.toContain("corg-2"); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.ts b/src/features/Org2Cloud/org2CloudSyncEngine.ts index 87e1ddc3ad..e13f08c86f 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.ts @@ -447,8 +447,16 @@ export class Org2CloudSyncEngine extends Org2CloudSyncLifecycle { resolveOrgEndpoint(org, getCloudEndpoint()), ]) ); + // Explicitly tagged sessions must publish (and keep publishing + // updates) no matter which org the user is looking at — otherwise + // Move to Org completes its awaited pass without ever visiting the + // target org, reports success, and the session stays invisible to + // every other member until the owner happens to activate that org. const sessionPushOrgs = orgs.filter( - (org) => this.isActiveOrg(org.orgId) || isOrgBackgroundUploadEnabled(org) + (org) => + this.isActiveOrg(org.orgId) || + isOrgBackgroundUploadEnabled(org) || + orgsWithTaggedSessions.has(org.orgId) ); await this.repoScopeSync.hydrateRepoScopes( fresh, From 748877ad2f9dca545208b70680af85473de5acd7 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:47:45 -0700 Subject: [PATCH 7/7] chore(logs): quiet routine startup warnings Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../key-vault/src/commands/registry/data/env_config.rs | 3 ++- .../key-vault/src/commands/registry/data/install_methods.rs | 5 ++++- src-tauri/src/agent_sessions/session_directory/conversion.rs | 4 +++- src-tauri/src/setup/worktree.rs | 5 ++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/key-vault/src/commands/registry/data/env_config.rs b/src-tauri/crates/key-vault/src/commands/registry/data/env_config.rs index e7d76cca4c..90fe017d22 100644 --- a/src-tauri/crates/key-vault/src/commands/registry/data/env_config.rs +++ b/src-tauri/crates/key-vault/src/commands/registry/data/env_config.rs @@ -159,7 +159,8 @@ pub(crate) fn cli_env_config(name: &str) -> Option { // Agents without a single universal API-key env var for ORGII to inject. // Some are subscription-token based, while others require provider-specific // config files or auth stores instead of one standard env-config path. - "aug" | "droid" | "autohand" | "omp" | "pi" | "open_claw" | "openclaw" | "antigravity" => { + "aug" | "droid" | "autohand" | "omp" | "pi" | "open_claw" | "openclaw" | "antigravity" + | "opencode" | "qoder_cli" | "trae_cli" => { None } // The caller iterates `cli_agent_registry()` entries, so a CLI diff --git a/src-tauri/crates/key-vault/src/commands/registry/data/install_methods.rs b/src-tauri/crates/key-vault/src/commands/registry/data/install_methods.rs index 14b05dc49f..0722326d0b 100644 --- a/src-tauri/crates/key-vault/src/commands/registry/data/install_methods.rs +++ b/src-tauri/crates/key-vault/src/commands/registry/data/install_methods.rs @@ -223,6 +223,9 @@ pub(crate) fn cli_install_methods(name: &str) -> Vec { // Trae Agent currently documents a repository checkout plus `uv sync`, // not a safe global install command for the registry to execute. "trae_cli" => Vec::new(), + // opencode's docs steer through per-platform package managers; no + // single command is safe for the registry to run everywhere yet. + "opencode" => Vec::new(), // The caller iterates `cli_agent_registry()` entries, so any // CLI agent that ships in the registry but has no install_methods // entry here would silently render the "Install" UI as a no-op. @@ -315,7 +318,7 @@ pub(crate) fn cli_uninstall_methods(name: &str) -> Vec { )], // Neither project currently documents a non-destructive uninstall // command that the registry can safely run on every supported OS. - "qoder_cli" | "trae_cli" => Vec::new(), + "opencode" | "qoder_cli" | "trae_cli" => Vec::new(), // Same fail-loud principle as `cli_install_methods` above. other => { tracing::warn!( diff --git a/src-tauri/src/agent_sessions/session_directory/conversion.rs b/src-tauri/src/agent_sessions/session_directory/conversion.rs index b27caee45e..89a346c2f7 100644 --- a/src-tauri/src/agent_sessions/session_directory/conversion.rs +++ b/src-tauri/src/agent_sessions/session_directory/conversion.rs @@ -36,7 +36,9 @@ fn warn_once_for_definition(def_id: &str, err: &str) { let warned = WARNED_DEFINITION_IDS.get_or_init(|| std::sync::Mutex::new(HashSet::new())); let mut warned = warned.lock().expect("definition warn set poisoned"); if warned.insert(def_id.to_string()) { - tracing::warn!( + // Stale ids are routine (deleted custom agents, e2e fixtures); + // thousands of distinct ones would drown the log at warn level. + tracing::debug!( "[session_directory] Failed to resolve agent definition '{def_id}' for aggregate metadata: {err}" ); } diff --git a/src-tauri/src/setup/worktree.rs b/src-tauri/src/setup/worktree.rs index c93f9a51b6..8b9c70e241 100644 --- a/src-tauri/src/setup/worktree.rs +++ b/src-tauri/src/setup/worktree.rs @@ -42,7 +42,10 @@ pub(crate) fn prune_stale_agent_worktrees() -> Result<(), String> { let mut total_pruned = 0u32; for repo_path in &repos_seen { let repo = std::path::Path::new(repo_path); - if !repo.is_dir() { + // Recorded paths can point at directories that were never + // repositories (e2e temp dirs); the marker check keeps prune from + // spawning a doomed git process and warning on every launch. + if !repo.is_dir() || !repo.join(".git").exists() { continue; } match git::worktree::prune_stale_worktrees(repo, &active_ids) {