Skip to content

Commit 7ae224c

Browse files
authored
v0.8.50: search
2 parents 9d00669 + e07e7ba commit 7ae224c

14 files changed

Lines changed: 659 additions & 151 deletions

‎apps/docs/content/docs/search/slack.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,11 @@ To switch apps, first remove Slack connections under **Settings → Sources →
115115

116116
## Permissions reference
117117

118-
New Search member connections request these read-only **User Token Scopes**. DM scopes are requested even when DM indexing is off; the source settings determine what is indexed. Bot scopes are separate and allow Sim to receive and answer questions in Slack.
118+
New Search member connections request these read-only **User Token Scopes**. DM scopes are requested even when DM indexing is off; the source settings determine what is indexed. Bot scopes are separate and allow Sim to receive and answer questions in Slack and list channels during source setup.
119+
120+
The custom app's **Bot Token Scopes** include `channels:read` and `groups:read` for the channel picker. Private channels appear only when the bot has access. For an existing app, add these scopes under **OAuth & Permissions**, reinstall the app in Slack to approve the changes, then reconnect the bot in Sim.
121+
122+
Custom and official app manifests declare the same full bot and user scope sets, including permissions reserved for additional capabilities. The table below lists the scopes requested by member indexing; declaring additional user scopes in the manifest does not automatically grant them to each member connection.
119123

120124
| Purpose | User scopes |
121125
|---|---|
@@ -146,5 +150,6 @@ See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manife
146150
| Redirect mismatch | Check all three redirect URLs above against your Sim origin. |
147151
| App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. |
148152
| Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. |
153+
| Channel picker says Options unavailable | Check that the selected custom bot has `channels:read` and `groups:read` under Bot Token Scopes. After adding them, reinstall the app in Slack and reconnect the bot in Sim. |
149154
| Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. |
150155
| Slow initial indexing | Check sync status and Slack rate limits. A large history can take multiple background runs. |
Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { task, tasks } from '@trigger.dev/sdk'
22
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
33
import {
4+
PROJECTION_SOURCE_ACL_BACKFILL_SHARDS,
45
PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID,
56
type ProjectionSourceAclBackfillPayload,
7+
projectionSourceAclChainTag,
68
runProjectionSourceAclBackfill,
79
} from '@/lib/knowledge/search/projection-source-acl-backfill'
810

@@ -13,23 +15,30 @@ const RUN_BUDGET_MS = 60 * 60 * 1000
1315
* Trigger.dev wrapper around `runProjectionSourceAclBackfill`. A run fills unset rows for up to
1416
* {@link RUN_BUDGET_MS}, then triggers its continuation from the cursor it reached, so the whole
1517
* projection is filled across as many bounded runs as it takes. Retry-safe: every run writes only
16-
* rows still unset, so a retried or restarted run repeats no write. The queue admits one run at a
17-
* time, so two starts never fill the same pages against each other.
18+
* rows still unset, so a retried or restarted run repeats no write. A shard's continuation keeps
19+
* its shard, so a sliced fill stays sliced until every slice is done.
1820
*/
1921
export const projectionSourceAclBackfillTask = task({
2022
id: PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID,
2123
machine: 'small-1x',
2224
retry: { maxAttempts: 3 },
25+
/**
26+
* One run per shard the id space may be sliced into. Shards fill disjoint ranges, so runs never
27+
* fill the same page against each other; an unsliced chain still runs one at a time because each
28+
* run triggers its continuation only as it ends.
29+
*/
2330
queue: {
2431
name: PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID,
25-
concurrencyLimit: 1,
32+
concurrencyLimit: PROJECTION_SOURCE_ACL_BACKFILL_SHARDS,
2633
},
2734
run: async (payload: ProjectionSourceAclBackfillPayload) => {
2835
const cursor = await runProjectionSourceAclBackfill(payload, { budgetMs: RUN_BUDGET_MS })
2936
if (!cursor) return
3037
const continuation: ProjectionSourceAclBackfillPayload = { ...payload, cursor }
3138
await tasks.trigger(PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID, continuation, {
3239
region: await resolveTriggerRegion(),
40+
/** The chain's tag rides on every continuation, so a start finds the chain wherever it is. */
41+
tags: [projectionSourceAclChainTag(payload.shard)],
3342
})
3443
},
3544
})

‎apps/sim/lib/internal/slack/oauth.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ describe('Slack bot grant policy and cleanup', () => {
7676
it('accepts the existing indexing bot scope policy', () => {
7777
expect(() => validateSlackBotAuthorization(grant)).not.toThrow()
7878
})
79+
it.each(['channels:read', 'groups:read'] as const)(
80+
'rejects a bot grant missing channel picker scope %s',
81+
(missingScope) => {
82+
expect(() =>
83+
validateSlackBotAuthorization({
84+
...grant,
85+
scope: SLACK_SEARCH_SCOPES.filter((scope) => scope !== missingScope).join(','),
86+
})
87+
).toThrow(`Reinstall the app with these scopes: ${missingScope}`)
88+
}
89+
)
7990
it('requires the additional command scope for shared installs', () => {
8091
expect(() =>
8192
validateSlackBotAuthorization(grant, [...SLACK_SEARCH_SCOPES, 'commands'])

‎apps/sim/lib/internal/slack/search-client.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ describe('Slack Search provider verification', () => {
4545
fetchMock.mockResolvedValue(new Response(JSON.stringify(auth)))
4646
await expect(verifySlackSearchBot('token')).rejects.toThrow('Reinstall')
4747
})
48+
it.each(['channels:read', 'groups:read'] as const)(
49+
'rejects an installed bot missing channel picker scope %s',
50+
async (missingScope) => {
51+
fetchMock.mockResolvedValue(
52+
reply(
53+
auth,
54+
SLACK_SEARCH_SCOPES.filter((scope) => scope !== missingScope)
55+
)
56+
)
57+
await expect(verifySlackSearchBot('token')).rejects.toThrow(
58+
`Reinstall the Slack bot with these scopes: ${missingScope}`
59+
)
60+
expect(fetchMock).toHaveBeenCalledOnce()
61+
}
62+
)
4863
it.each([
4964
{ deleted: true },
5065
{ is_bot: true },

‎apps/sim/lib/knowledge/search/projection-source-acl-backfill.test.ts‎

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,24 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockBackfill, mockEnd, mockPostgres, mockPrewarm, mockTasksTrigger } = vi.hoisted(() => ({
6+
const {
7+
mockBackfill,
8+
mockEnd,
9+
mockPostgres,
10+
mockPrewarm,
11+
mockRunsList,
12+
mockTasksTrigger,
13+
mockUnsafe,
14+
} = vi.hoisted(() => ({
715
mockBackfill: vi.fn(),
816
mockEnd: vi.fn(async () => undefined),
917
mockPostgres: vi.fn(),
1018
mockPrewarm: vi.fn(async () => []),
19+
mockRunsList: vi.fn(
20+
(_query: unknown): AsyncIterable<{ id: string; status: string }> => (async function* () {})()
21+
),
1122
mockTasksTrigger: vi.fn(async () => ({ id: 'run-1' })),
23+
mockUnsafe: vi.fn(async () => [{ unfilled: false }]),
1224
}))
1325

1426
vi.mock('@sim/db', () => ({ resolveDbUrl: () => 'postgres://localhost:5432/sim' }))
@@ -18,7 +30,10 @@ vi.mock('@sim/db/script-migrations/0021_embedding_search_connector', () => ({
1830
}))
1931
vi.mock('postgres', () => ({ default: mockPostgres }))
2032
vi.mock('@/lib/knowledge/search/prewarm', () => ({ prewarmSearchProjection: mockPrewarm }))
21-
vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mockTasksTrigger } }))
33+
vi.mock('@trigger.dev/sdk', () => ({
34+
runs: { list: mockRunsList },
35+
tasks: { trigger: mockTasksTrigger },
36+
}))
2237
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' }))
2338
vi.mock('@/lib/core/utils/background', () => ({
2439
runDetached: (_label: string, work: () => Promise<unknown>) => {
@@ -29,10 +44,11 @@ vi.mock('@/lib/core/utils/background', () => ({
2944
import {
3045
enqueueProjectionSourceAclBackfill,
3146
PROJECTION_PREWARM_BUDGET_MS,
47+
projectionSourceAclShardRange,
3248
runProjectionSourceAclBackfill,
3349
} from '@/lib/knowledge/search/projection-source-acl-backfill'
3450

35-
const connection = { end: mockEnd }
51+
const connection = { end: mockEnd, unsafe: mockUnsafe }
3652

3753
describe('runProjectionSourceAclBackfill', () => {
3854
beforeEach(() => {
@@ -60,8 +76,17 @@ describe('runProjectionSourceAclBackfill', () => {
6076
expect(mockEnd).toHaveBeenCalledTimes(1)
6177
})
6278

63-
it('warms the projections on the same connection once both are filled, before closing it', async () => {
79+
it('analyzes and warms the projections on the same connection once both are filled, before closing it', async () => {
6480
await runProjectionSourceAclBackfill({})
81+
/** A row whose document is gone is not the fill's to finish; the probe joins the document. */
82+
expect(
83+
mockUnsafe.mock.calls.some(([query]) =>
84+
String(query).includes('JOIN document d ON d.id = s.document_id WHERE s.acl IS NULL')
85+
)
86+
).toBe(true)
87+
expect(mockUnsafe.mock.calls.map(([query]) => query)).toEqual(
88+
expect.arrayContaining(['ANALYZE embedding_search', 'ANALYZE embedding_keyword_tin'])
89+
)
6590
expect(mockPrewarm).toHaveBeenCalledTimes(1)
6691
expect(mockPrewarm).toHaveBeenCalledWith(connection, { budgetMs: PROJECTION_PREWARM_BUDGET_MS })
6792
expect(mockPrewarm.mock.invocationCallOrder[0]).toBeLessThan(
@@ -101,11 +126,65 @@ describe('runProjectionSourceAclBackfill', () => {
101126
await expect(runProjectionSourceAclBackfill({})).rejects.toThrow('statement timeout')
102127
expect(mockEnd).toHaveBeenCalledTimes(1)
103128
})
129+
130+
it('fills only its shard of the id space in both projections', async () => {
131+
await runProjectionSourceAclBackfill({ shard: { index: 1, count: 4 } })
132+
for (const [, , options] of mockBackfill.mock.calls) {
133+
expect(options).toMatchObject({ afterId: '4', beforeId: '8' })
134+
}
135+
})
136+
137+
it('resumes a shard after its cursor and keeps its upper bound', async () => {
138+
await runProjectionSourceAclBackfill({
139+
shard: { index: 1, count: 4 },
140+
cursor: { projection: 'embedding_search', afterId: '5a' },
141+
})
142+
expect(mockBackfill.mock.calls[0][2]).toMatchObject({ afterId: '5a', beforeId: '8' })
143+
expect(mockBackfill.mock.calls[1][2]).toMatchObject({ afterId: '4', beforeId: '8' })
144+
})
145+
146+
it('leaves the analysis and the warm to whoever fills the rows another shard still holds', async () => {
147+
mockUnsafe.mockResolvedValueOnce([{ unfilled: true }])
148+
await expect(
149+
runProjectionSourceAclBackfill({ shard: { index: 0, count: 4 } })
150+
).resolves.toBeNull()
151+
expect(mockUnsafe.mock.calls.map(([query]) => query)).not.toContain('ANALYZE embedding_search')
152+
expect(mockPrewarm).not.toHaveBeenCalled()
153+
expect(mockEnd).toHaveBeenCalledTimes(1)
154+
})
155+
})
156+
157+
describe('projectionSourceAclShardRange', () => {
158+
it('slices the hex id space into contiguous ranges', () => {
159+
expect(projectionSourceAclShardRange({ index: 0, count: 4 })).toEqual({
160+
afterId: '',
161+
beforeId: '4',
162+
})
163+
expect(projectionSourceAclShardRange({ index: 3, count: 4 })).toEqual({
164+
afterId: 'c',
165+
beforeId: undefined,
166+
})
167+
expect(projectionSourceAclShardRange({ index: 0, count: 1 })).toEqual({
168+
afterId: '',
169+
beforeId: undefined,
170+
})
171+
})
172+
173+
it.each([
174+
[{ index: 0, count: 3 }, 'shard count must divide 16'],
175+
[{ index: 0, count: 8 }, 'shard count must be at most 4'],
176+
[{ index: 4, count: 4 }, 'shard index must be within 0..3'],
177+
[{ index: 0.5, count: 2 }, 'shard index must be within 0..1'],
178+
])('refuses %j', (shard, message) => {
179+
expect(() => projectionSourceAclShardRange(shard)).toThrow(message)
180+
})
104181
})
105182

106183
describe('enqueueProjectionSourceAclBackfill', () => {
107184
beforeEach(() => {
108185
vi.clearAllMocks()
186+
/** No chain in flight unless a case says so. */
187+
mockRunsList.mockImplementation(() => (async function* () {})())
109188
mockPostgres.mockReturnValue(connection)
110189
mockBackfill.mockResolvedValue({
111190
projection: 'embedding_search',
@@ -118,13 +197,91 @@ describe('enqueueProjectionSourceAclBackfill', () => {
118197

119198
it('hands the backfill to the Trigger.dev worker when one is configured', async () => {
120199
await expect(enqueueProjectionSourceAclBackfill({ pageSize: 25 })).resolves.toEqual({
121-
runId: 'run-1',
200+
runIds: ['run-1'],
201+
inFlight: [],
122202
})
203+
expect(mockRunsList).toHaveBeenCalledWith(
204+
expect.objectContaining({ tag: 'projection-source-acl-backfill:shard:0/1' })
205+
)
123206
expect(mockTasksTrigger).toHaveBeenCalledWith(
124207
'projection-source-acl-backfill',
125208
{ pageSize: 25 },
126-
{ region: 'us-east-1' }
209+
{
210+
region: 'us-east-1',
211+
tags: ['projection-source-acl-backfill:shard:0/1'],
212+
idempotencyKey: 'projection-source-acl-backfill:shard:0/1:after:none',
213+
idempotencyKeyTTL: '2m',
214+
}
127215
)
128216
expect(mockBackfill).not.toHaveBeenCalled()
129217
})
218+
219+
it('keys a start after a chain that ended on that chain, so a restart is its own start', async () => {
220+
mockRunsList.mockImplementation(() =>
221+
(async function* () {
222+
yield { id: 'run-done', status: 'COMPLETED' }
223+
})()
224+
)
225+
await expect(enqueueProjectionSourceAclBackfill({})).resolves.toEqual({
226+
runIds: ['run-1'],
227+
inFlight: [],
228+
})
229+
expect(mockTasksTrigger.mock.calls[0][2].idempotencyKey).toBe(
230+
'projection-source-acl-backfill:shard:0/1:after:run-done'
231+
)
232+
})
233+
234+
it('refuses a shard the id space cannot be sliced into before starting anything', async () => {
235+
await expect(
236+
enqueueProjectionSourceAclBackfill({ shard: { index: 5, count: 4 } })
237+
).rejects.toThrow('shard index must be within 0..3')
238+
expect(mockTasksTrigger).not.toHaveBeenCalled()
239+
})
240+
241+
it('leaves a range whose chain is still in flight to that chain', async () => {
242+
mockRunsList.mockImplementation((query: unknown) =>
243+
(async function* () {
244+
if ((query as { tag: string }).tag.endsWith(':shard:1/4'))
245+
yield { id: 'run-live', status: 'EXECUTING' }
246+
})()
247+
)
248+
await expect(enqueueProjectionSourceAclBackfill({}, 4)).resolves.toEqual({
249+
runIds: ['run-1', 'run-1', 'run-1'],
250+
inFlight: ['run-live'],
251+
})
252+
expect(mockTasksTrigger.mock.calls.map(([, payload]) => payload.shard?.index)).toEqual([
253+
0, 2, 3,
254+
])
255+
})
256+
257+
it('starts one run per shard, each on its own slice under its own chain tag', async () => {
258+
await expect(enqueueProjectionSourceAclBackfill({ pageSize: 25 }, 4)).resolves.toEqual({
259+
runIds: ['run-1', 'run-1', 'run-1', 'run-1'],
260+
inFlight: [],
261+
})
262+
expect(mockTasksTrigger.mock.calls.map(([, payload]) => payload)).toEqual(
263+
[0, 1, 2, 3].map((index) => ({ pageSize: 25, shard: { index, count: 4 } }))
264+
)
265+
expect(mockTasksTrigger.mock.calls.map(([, , options]) => options.tags)).toEqual(
266+
[0, 1, 2, 3].map((index) => [`projection-source-acl-backfill:shard:${index}/4`])
267+
)
268+
})
269+
270+
it.each([
271+
[3, 'must divide 16'],
272+
[8, 'must be at most 4'],
273+
])('refuses %s shards before starting anything', async (shards, message) => {
274+
await expect(enqueueProjectionSourceAclBackfill({}, shards)).rejects.toThrow(message)
275+
expect(mockTasksTrigger).not.toHaveBeenCalled()
276+
})
277+
278+
it('refuses to slice a start that carries a cursor, which belongs to one chain', async () => {
279+
await expect(
280+
enqueueProjectionSourceAclBackfill(
281+
{ cursor: { projection: 'embedding_search', afterId: '5a' } },
282+
4
283+
)
284+
).rejects.toThrow('cannot start from a cursor')
285+
expect(mockTasksTrigger).not.toHaveBeenCalled()
286+
})
130287
})

0 commit comments

Comments
 (0)