| ... | @@ -87,6 +87,13 @@ type ThreadDetailSubscriptionEntry = { | ... | @@ -87,6 +87,13 @@ type ThreadDetailSubscriptionEntry = { |
| 87 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | 87 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 88 | const environmentConnectionListeners = new Set<() => void>(); | 88 | const environmentConnectionListeners = new Set<() => void>(); |
| 89 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); | 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 | let activeService: EnvironmentServiceState | null = null; | 98 | let activeService: EnvironmentServiceState | null = null; |
| 92 | let needsProviderInvalidation = false; | 99 | let needsProviderInvalidation = false; |
| ... | @@ -102,6 +109,98 @@ const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; | ... | @@ -102,6 +109,98 @@ const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; |
| 102 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; | 109 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; |
| 103 | const NOOP = () => undefined; | 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 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { | 204 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { |
| 106 | return scopedThreadKey(scopeThreadRef(environmentId, threadId)); | 205 | return scopedThreadKey(scopeThreadRef(environmentId, threadId)); |
| 107 | } | 206 | } |
| ... | @@ -600,6 +699,15 @@ export function applyEnvironmentThreadDetailEvent( | ... | @@ -600,6 +699,15 @@ export function applyEnvironmentThreadDetailEvent( |
| 600 | } | 699 | } |
| 601 | | 700 | |
| 602 | function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { | 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 | const threadId = | 711 | const threadId = |
| 604 | event.kind === "thread-upserted" | 712 | event.kind === "thread-upserted" |
| 605 | ? event.thread.id | 713 | ? event.thread.id |
| ... | @@ -610,6 +718,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En | ... | @@ -610,6 +718,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En |
| 610 | const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; | 718 | const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; |
| 611 | | 719 | |
| 612 | useStore.getState().applyShellEvent(event, environmentId); | 720 | useStore.getState().applyShellEvent(event, environmentId); |
| | 721 | markAppliedProjectionEvent(environmentId, event.sequence); |
| 613 | | 722 | |
| 614 | switch (event.kind) { | 723 | switch (event.kind) { |
| 615 | case "project-upserted": | 724 | case "project-upserted": |
| ... | @@ -643,7 +752,17 @@ function createEnvironmentConnectionHandlers() { | ... | @@ -643,7 +752,17 @@ function createEnvironmentConnectionHandlers() { |
| 643 | return { | 752 | return { |
| 644 | applyShellEvent, | 753 | applyShellEvent, |
| 645 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { | 754 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { |
| | 755 | if ( |
| | 756 | !shouldApplyProjectionSnapshot({ |
| | 757 | current: readLastAppliedProjectionVersion(environmentId), |
| | 758 | next: snapshot, |
| | 759 | }) |
| | 760 | ) { |
| | 761 | return; |
| | 762 | } |
| | 763 | |
| 646 | useStore.getState().syncServerShellSnapshot(snapshot, environmentId); | 764 | useStore.getState().syncServerShellSnapshot(snapshot, environmentId); |
| | 765 | markAppliedProjectionSnapshot(environmentId, snapshot); |
| 647 | reconcileThreadDetailSubscriptionsForEnvironment( | 766 | reconcileThreadDetailSubscriptionsForEnvironment( |
| 648 | environmentId, | 767 | environmentId, |
| 649 | snapshot.threads.map((thread) => thread.id), | 768 | snapshot.threads.map((thread) => thread.id), |
| ... | @@ -758,6 +877,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> | ... | @@ -758,6 +877,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 758 | } | 877 | } |
| 759 | | 878 | |
| 760 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); | 879 | disposeThreadDetailSubscriptionsForEnvironment(environmentId); |
| | 880 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); |
| 761 | environmentConnections.delete(environmentId); | 881 | environmentConnections.delete(environmentId); |
| 762 | emitEnvironmentConnectionRegistryChange(); | 882 | emitEnvironmentConnectionRegistryChange(); |
| 763 | await connection.dispose(); | 883 | await connection.dispose(); |
| ... | @@ -1086,6 +1206,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () | ... | @@ -1086,6 +1206,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 1086 | | 1206 | |
| 1087 | export async function resetEnvironmentServiceForTests(): Promise<void> { | 1207 | export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 1088 | stopActiveService(); | 1208 | stopActiveService(); |
| | 1209 | lastAppliedProjectionVersionByEnvironment.clear(); |
| 1089 | for (const key of Array.from(threadDetailSubscriptions.keys())) { | 1210 | for (const key of Array.from(threadDetailSubscriptions.keys())) { |
| 1090 | disposeThreadDetailSubscriptionByKey(key); | 1211 | disposeThreadDetailSubscriptionByKey(key); |
| 1091 | } | 1212 | } |