Skip to content

Commit 961eb78

Browse files
committed
fix(knowledge): treat a reclaimed lease during a member purge as superseded; tidy the chat reply sources after cleanup
1 parent 108356d commit 961eb78

6 files changed

Lines changed: 67 additions & 29 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources.tsx‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/component
77

88
/** The action-row button, matching the copy and vote buttons beside it with room for a count. */
99
const BUTTON_CLASSES =
10-
'flex h-[26px] items-center gap-1 rounded-[6px] px-1.5 text-[var(--text-icon)] text-caption transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none data-[state=open]:bg-[var(--surface-hover)]'
10+
'flex h-[26px] items-center gap-1 rounded-[6px] px-1.5 text-[var(--text-icon)] text-caption transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none data-[state=open]:bg-[var(--surface-active)] data-[state=open]:hover-hover:bg-[var(--surface-active)]'
1111

1212
interface MessageSourcesProps {
1313
sources: readonly SourceTagData[]
@@ -36,12 +36,7 @@ export function MessageSources({ sources }: MessageSourcesProps) {
3636
</Tooltip.Trigger>
3737
<Tooltip.Content side='top'>{label}</Tooltip.Content>
3838
</Tooltip.Root>
39-
<PopoverContent
40-
align='start'
41-
side='top'
42-
sideOffset={4}
43-
className='w-[420px] max-w-[90vw] p-0'
44-
>
39+
<PopoverContent align='start' side='top' sideOffset={4} className='w-[420px] p-0'>
4540
<div className='flex flex-col py-1'>
4641
{sources.map((source) => (
4742
<SourceCard key={source.url} source={source} dense />

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,12 @@ export function SourceCard({ source, query, onSummarize, dense = false }: Source
151151

152152
if (dense) {
153153
return (
154-
<div className={cn(SOURCE_ROW_CLASSES, 'items-center py-1')}>
154+
<div
155+
className={cn(
156+
SOURCE_ROW_CLASSES,
157+
'items-center py-1 focus-within:bg-[var(--surface-hover)] hover-hover:bg-[var(--surface-hover)]'
158+
)}
159+
>
155160
<span className={chipIconSlotClass}>{mark}</span>
156161
<a
157162
href={source.url}
@@ -168,7 +173,7 @@ export function SourceCard({ source, query, onSummarize, dense = false }: Source
168173
</a>
169174
<OverflowText
170175
label={meta.join(' · ')}
171-
className='max-w-[40%] shrink-0 text-[var(--text-muted)] text-caption'
176+
className='max-w-[40%] flex-shrink-0 text-[var(--text-muted)] text-caption'
172177
/>
173178
<div className='flex flex-shrink-0 items-center opacity-0 transition-opacity group-focus-within/source:opacity-100 group-hover/source:opacity-100 [@media(hover:none)]:opacity-100'>
174179
<CopyLinkAction url={source.url} />

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,6 @@ function MessageContentInner({
958958
trailingPendingTag ||
959959
(isStreamIdle && !trailingStreamActivity && !hasExecutingTool))
960960

961-
/** The action buttons and, when the reply cited documents, the sources button beside them. */
962961
const actionsRow = (
963962
<div className='flex items-center gap-0.5'>
964963
{actions}

‎apps/sim/app/workspace/[workspaceId]/home/home.tsx‎

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,7 @@ export function Home({ chatId, userName, userId }: HomeProps) {
198198
/** The bases an Ask turn is grounded in; read through a ref so a list refresh never rebuilds the submit handler. */
199199
const { data: knowledgeBases = EMPTY_KNOWLEDGE_BASES } = useKnowledgeBasesQuery(workspaceId)
200200
const knowledgeBasesRef = useRef(knowledgeBases)
201-
useEffect(() => {
202-
knowledgeBasesRef.current = knowledgeBases
203-
}, [knowledgeBases])
201+
knowledgeBasesRef.current = knowledgeBases
204202
const hasCheckedLandingStorageRef = useRef(false)
205203
const initialViewInputRef = useRef<HTMLDivElement>(null)
206204
const initialViewUserInputRef = useRef<UserInputHandle>(null)
@@ -503,7 +501,6 @@ export function Home({ chatId, userName, userId }: HomeProps) {
503501
}
504502

505503
prepareResourceViewForAgentTurn()
506-
/** Ask is a turn of the agent grounded in the searched sources. */
507504
const turnContexts =
508505
mode === 'ask'
509506
? withSearchedKnowledgeContexts(
@@ -525,14 +522,11 @@ export function Home({ chatId, userName, userId }: HomeProps) {
525522
const clearSearch = useCallback(() => setSearchQuery(''), [setSearchQuery])
526523

527524
/** Summarize or Answer on a result: hand the question to the agent in Ask mode. */
528-
const handleSummarize = useCallback(
529-
(prompt: string) => {
530-
useMothershipModeStore.getState().setMode('ask')
531-
setSearchQuery('')
532-
handleSubmit(prompt)
533-
},
534-
[handleSubmit, setSearchQuery]
535-
)
525+
const handleSummarize = (prompt: string) => {
526+
useMothershipModeStore.getState().setMode('ask')
527+
setSearchQuery('')
528+
handleSubmit(prompt)
529+
}
536530
/**
537531
* A chat that already exists never opens in Search: its transcript is a
538532
* conversation, and search results never join it. Build and Ask both carry

‎apps/sim/lib/knowledge/connectors/member-observations.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
vi.mock('@/lib/knowledge/documents/service', () => ({
8+
ConnectorSyncDeletionGuardError: class ConnectorSyncDeletionGuardError extends Error {},
89
hardDeleteDocuments: vi.fn(),
910
}))
1011

12+
import { db } from '@sim/db'
1113
import {
14+
applyMemberDocumentLifecycle,
1215
rewriteConnectorAcls,
1316
staleMemberWindowMs,
1417
sweepStaleMemberObservations,
1518
} from '@/lib/knowledge/connectors/member-observations'
1619
import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits'
1720
import { SyncLockLostException } from '@/lib/knowledge/connectors/sync-lock'
21+
import {
22+
ConnectorSyncDeletionGuardError,
23+
hardDeleteDocuments,
24+
} from '@/lib/knowledge/documents/service'
1825

1926
const NOW = new Date('2026-09-01T12:00:00Z')
2027
const STALE_MEMBER = { id: 'm-1', connectorId: 'c-1', syncIntervalMinutes: 60 }
@@ -120,3 +127,31 @@ describe('rewriteConnectorAcls', () => {
120127
expect(dbChainMockFns.update).not.toHaveBeenCalled()
121128
})
122129
})
130+
131+
describe('applyMemberDocumentLifecycle', () => {
132+
beforeEach(() => {
133+
resetDbChainMock()
134+
vi.mocked(hardDeleteDocuments).mockReset()
135+
})
136+
137+
it('reports a reclaimed lease during a purge batch as the run being superseded', async () => {
138+
dbChainMockFns.returning.mockResolvedValueOnce([])
139+
queueTableRows(schemaMock.document, [])
140+
queueTableRows(schemaMock.document, [{ id: 'd-1' }])
141+
vi.mocked(hardDeleteDocuments).mockRejectedValueOnce(
142+
new ConnectorSyncDeletionGuardError('lease reclaimed')
143+
)
144+
145+
await expect(
146+
applyMemberDocumentLifecycle({
147+
connectorId: 'c-1',
148+
knowledgeBaseId: 'kb-1',
149+
runId: 'run-1',
150+
withLease: (fn) => fn(db as never),
151+
failedExternalIds: new Set(),
152+
allowRemoval: true,
153+
lease: { beatIfDue: async () => {} } as never,
154+
})
155+
).rejects.toBeInstanceOf(SyncLockLostException)
156+
})
157+
})

‎apps/sim/lib/knowledge/connectors/member-observations.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ import {
3030
assertSyncLeaseHeldInTx,
3131
connectorIsLive,
3232
MEMBER_LOCKABLE_CONNECTOR_STATUSES,
33+
SyncLockLostException,
3334
type SyncRunLease,
3435
type SyncWriteLease,
3536
} from '@/lib/knowledge/connectors/sync-lock'
3637
import {
3738
type ConnectorSyncDeletionGuard,
39+
ConnectorSyncDeletionGuardError,
3840
hardDeleteDocuments,
3941
} from '@/lib/knowledge/documents/service'
4042

@@ -364,13 +366,21 @@ export async function applyMemberDocumentLifecycle(input: {
364366
const purgeIds = purgeCandidates.map((row) => row.id)
365367
for (let offset = 0; offset < purgeIds.length; offset += PURGE_CHUNK_SIZE) {
366368
await input.lease.beatIfDue()
367-
purged += await hardDeleteDocuments(
368-
purgeIds.slice(offset, offset + PURGE_CHUNK_SIZE),
369-
runId,
370-
connectorId,
371-
knowledgeBaseId,
372-
guard
373-
)
369+
try {
370+
purged += await hardDeleteDocuments(
371+
purgeIds.slice(offset, offset + PURGE_CHUNK_SIZE),
372+
runId,
373+
connectorId,
374+
knowledgeBaseId,
375+
guard
376+
)
377+
} catch (error) {
378+
/** The deletion guard refusing the lease is a reclaimed run, not a failed one. */
379+
if (error instanceof ConnectorSyncDeletionGuardError) {
380+
throw new SyncLockLostException(connectorId)
381+
}
382+
throw error
383+
}
374384
}
375385

376386
return { tombstoned: tombstoned.length, resurrected: resurrected.length, purged }

0 commit comments

Comments
 (0)