| ... | ... | @@ -87,6 +87,13 @@ type ThreadDetailSubscriptionEntry = { |
| 87 | 87 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 88 | 88 | const environmentConnectionListeners = new Set<() => void>(); |
| 89 | 89 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); |
| 90 | const lastAppliedProjectionVersionByEnvironment = new Map< |
| 91 | EnvironmentId, |
| 92 | { |
| 93 | readonly sequence: number; |
| 94 | readonly updatedAt: string | null; |
| 95 | } |
| 96 | >(); |
| 90 | 97 | |
| 91 | 98 | let activeService: EnvironmentServiceState | null = null; |
| 92 | 99 | let needsProviderInvalidation = false; |
| ... | ... | @@ -102,6 +109,98 @@ const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; |
| 102 | 109 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; |
| 103 | 110 | const NOOP = () => undefined; |
| 104 | 111 | |
| 112 | function compareAppliedProjectionVersion( |
| 113 | left: { readonly sequence: number; readonly updatedAt: string | null }, |
| 114 | right: { readonly sequence: number; readonly updatedAt: string | null }, |
| 115 | ): number { |
| 116 | if (left.sequence !== right.sequence) { |
| 117 | return left.sequence - right.sequence; |
| 118 | } |
| 119 | |
| 120 | const leftUpdatedAt = left.updatedAt ?? ""; |
| 121 | const rightUpdatedAt = right.updatedAt ?? ""; |
| 122 | if (leftUpdatedAt === rightUpdatedAt) { |
| 123 | return 0; |
| 124 | } |
| 125 | |
| 126 | return leftUpdatedAt < rightUpdatedAt ? -1 : 1; |
| 127 | } |
| 128 | |
| 129 | function toAppliedProjectionVersion( |
| 130 | snapshot: Pick<OrchestrationShellSnapshot, "snapshotSequence" | "updatedAt">, |
| 131 | ): { |
| 132 | readonly sequence: number; |
| 133 | readonly updatedAt: string; |
| 134 | } { |
| 135 | return { |
| 136 | sequence: snapshot.snapshotSequence, |
| 137 | updatedAt: snapshot.updatedAt, |
| 138 | }; |
| 139 | } |
| 140 | |
| 141 | export function shouldApplyProjectionSnapshot(input: { |
| 142 | readonly current: { |
| 143 | readonly sequence: number; |
| 144 | readonly updatedAt: string | null; |
| 145 | } | null; |
| 146 | readonly next: Pick<OrchestrationShellSnapshot, "snapshotSequence" | "updatedAt">; |
| 147 | }): boolean { |
| 148 | if (input.current === null) { |
| 149 | return true; |
| 150 | } |
| 151 | |
| 152 | return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0; |
| 153 | } |
| 154 | |
| 155 | export function shouldApplyProjectionEvent(input: { |
| 156 | readonly current: { |
| 157 | readonly sequence: number; |
| 158 | readonly updatedAt: string | null; |
| 159 | } | null; |
| 160 | readonly sequence: number; |
| 161 | }): boolean { |
| 162 | if (input.current === null) { |
| 163 | return true; |
| 164 | } |
| 165 | |
| 166 | return input.sequence > input.current.sequence; |
| 167 | } |
| 168 | |
| 169 | function readLastAppliedProjectionVersion(environmentId: EnvironmentId): { |
| 170 | readonly sequence: number; |
| 171 | readonly updatedAt: string | null; |
| 172 | } | null { |
| 173 | return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null; |
| 174 | } |
| 175 | |
| 176 | function markAppliedProjectionSnapshot( |
| 177 | environmentId: EnvironmentId, |
| 178 | snapshot: Pick<OrchestrationShellSnapshot, "snapshotSequence" | "updatedAt">, |
| 179 | ): void { |
| 180 | const nextVersion = toAppliedProjectionVersion(snapshot); |
| 181 | const currentVersion = readLastAppliedProjectionVersion(environmentId); |
| 182 | if ( |
| 183 | currentVersion !== null && |
| 184 | compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0 |
| 185 | ) { |
| 186 | return; |
| 187 | } |
| 188 | |
| 189 | lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion); |
| 190 | } |
| 191 | |
| 192 | function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void { |
| 193 | const currentVersion = readLastAppliedProjectionVersion(environmentId); |
| 194 | if (currentVersion !== null && sequence <= currentVersion.sequence) { |
| 195 | return; |
| 196 | } |
| 197 | |
| 198 | lastAppliedProjectionVersionByEnvironment.set(environmentId, { |
| 199 | sequence, |
| 200 | updatedAt: currentVersion?.updatedAt ?? null, |
| 201 | }); |
| 202 | } |
| 203 | |
| 105 | 204 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { |
| 106 | 205 | return scopedThreadKey(scopeThreadRef(environmentId, threadId)); |
| 107 | 206 | } |
| ... | ... | @@ -600,6 +699,15 @@ export function applyEnvironmentThreadDetailEvent( |
| 600 | 699 | } |
| 601 | 700 | |
| 602 | 701 | function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { |
| 702 | if ( |
| 703 | !shouldApplyProjectionEvent({ |
| 704 | current: readLastAppliedProjectionVersion(environmentId), |
| 705 | sequence: event.sequence, |
| 706 | }) |
| 707 | ) { |
| 708 | return; |
| 709 | } |
| 710 | |
| 603 | 711 | const threadId = |
| 604 | 712 | event.kind === "thread-upserted" |
| 605 | 713 | ? event.thread.id |
| ... | ... | @@ -610,6 +718,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En |
| 610 | 718 | const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; |
| 611 | 719 | |
| 612 | 720 | useStore.getState().applyShellEvent(event, environmentId); |
| 721 | markAppliedProjectionEvent(environmentId, event.sequence); |
| 613 | 722 | |
| 614 | 723 | switch (event.kind) { |
| 615 | 724 | case "project-upserted": |
| ... | ... | @@ -643,7 +752,17 @@ function createEnvironmentConnectionHandlers() { |
| 643 | 752 | return { |
| 644 | 753 | applyShellEvent, |
| 645 | 754 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { |
| 755 | if ( |
| 756 | !shouldApplyProjectionSnapshot({ |
| 757 | current: readLastAppliedProjectionVersion(environmentId), |
| 758 | next: snapshot, |
| 759 | }) |
| 760 | ) { |
| 761 | return; |
| 762 | } |
| 763 | |
| 646 | 764 | useStore.getState().syncServerShellSnapshot(snapshot, environmentId); |
| 765 | markAppliedProjectionSnapshot(environmentId, snapshot); |
| 647 | 766 | reconcileThreadDetailSubscriptionsForEnvironment( |
| 648 | 767 | environmentId, |
| 649 | 768 | snapshot.threads.map((thread) => thread.id), |
| ... | ... | @@ -758,6 +877,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 758 | 877 | } |
| 759 | 878 | |
| 760 | 879 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); |
| 880 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); |
| 761 | 881 | environmentConnections.delete(environmentId); |
| 762 | 882 | emitEnvironmentConnectionRegistryChange(); |
| 763 | 883 | await connection.dispose(); |
| ... | ... | @@ -1086,6 +1206,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 1086 | 1206 | |
| 1087 | 1207 | export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 1088 | 1208 | stopActiveService(); |
| 1209 | lastAppliedProjectionVersionByEnvironment.clear(); |
| 1089 | 1210 | for (const key of Array.from(threadDetailSubscriptions.keys())) { |
| 1090 | 1211 | disposeThreadDetailSubscriptionByKey(key); |
| 1091 | 1212 | } |