Skip to content

Commit dbf585c

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/agent-fallback-models
2 parents 0b35cd7 + d0a9497 commit dbf585c

25 files changed

Lines changed: 55935 additions & 63 deletions

‎.agents/skills/ship/SKILL.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,19 @@ The repo is public. **Everything you publish — title, description, commit mess
112112

113113
Describe the bug by its mechanism, not by how you found it. "Expired OAuth credentials fail to refresh in the worker" — not "the Sheets canary failed at 16:31Z for workspace abc-123". Aggregate counts are fine once detached from the tenant ("1,379 PDFs failed"); the same number attributed to a named customer is not. Replace real examples with placeholders (`<real sheet name>`) rather than cutting them — the illustration is usually the useful part.
114114

115-
**Scrub before publishing, not after** — a leak is public the instant it posts, and editing later does not unsend the notification email. This applies to every PR you open, including ones created directly with `gh pr create` rather than through this skill. Grep the title, body, and `git log origin/staging..HEAD` before publishing:
115+
**Measurements are not the problem; absolute production scale is.** Keep the numbers that justify a change — durations, ratios, before/after timings, test and audit counts. They are the evidence a reviewer needs, and stripping them makes the rationale unfalsifiable. What does not belong is anything that sizes production or a tenant: table and index byte sizes, row/chunk/document totals, dead-tuple counts, buffer and heap-fetch counts, worker or instance counts. "Visiting four times as many tuples took 5.1s and 9.7s on consecutive runs" is fine; "on a 132k-chunk index" or "reclaims ~19 GB" is not. The same rule applies to code comments and migration comments, which are published exactly like a PR body — this is the most commonly missed case, because they do not feel like publishing.
116+
117+
**Scrub before publishing, not after** — a leak is public the instant it posts, and editing later does not unsend the notification email. This applies to every PR you open, including ones created directly with `gh pr create` rather than through this skill. Grep the title, body, `git log origin/staging..HEAD`, AND the diff itself before publishing:
116118

117119
```bash
120+
# identities, IDs, infrastructure
118121
grep -niE 'customer-or-company-name|@[a-z0-9.-]+\.(com|io|ai)|[0-9a-f]{8}-[0-9a-f]{4}-|\.sharepoint\.com|arn:aws|https?://[a-z0-9.-]*\.internal'
122+
# absolute production scale — byte sizes, k/M-scale entity counts, 7-figure totals
123+
grep -niE '[0-9][0-9.,]* ?(TB|GB)\b|[0-9]+(\.[0-9]+)?[kKmM][- ](row|chunk|document|vector|tuple|doc)|[0-9]{1,3}(,[0-9]{3}){2,}'
119124
```
120125

126+
The second pattern deliberately allows ordinary engineering numbers (`5.1s`, `46 audits`, `2,921 tests`) and flags only production sizing.
127+
121128
## PR Description Format
122129

123130
Use this exact template in the user's voice (concise, bullet points):

‎.github/workflows/test-build.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,14 @@ jobs:
9494
working-directory: packages/db
9595
run: bun run db:migrate
9696

97-
- name: Verify retired-column contract migration in PostgreSQL
97+
- name: Verify schema contract migrations in PostgreSQL
9898
working-directory: packages/db
9999
env:
100-
RETIRED_COLUMNS_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
101-
run: bunx vitest run scripts/retired-columns.postgres.test.ts
100+
MIGRATION_CONTRACT_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
101+
run: >-
102+
bunx vitest run
103+
scripts/retired-columns.postgres.test.ts
104+
scripts/connector-sync-schedule-precision.postgres.test.ts
102105
103106
- name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL
104107
working-directory: apps/sim

‎apps/sim/background/workspace-file-search-index.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('workspace file search index task', () => {
3535
it('uses isolated medium workers with a hard global concurrency and duration cap', () => {
3636
expect(workspaceFileSearchIndexTask).toMatchObject({
3737
id: 'workspace-file-search-index',
38-
machine: 'medium-1x',
38+
machine: 'medium-2x',
3939
maxDuration: FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
4040
retry: { maxAttempts: 3 },
4141
queue: {

‎apps/sim/background/workspace-file-search-index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
*/
1616
export const workspaceFileSearchIndexTask = task({
1717
id: 'workspace-file-search-index',
18-
machine: 'medium-1x',
18+
machine: 'medium-2x',
1919
maxDuration: FILE_SEARCH_INDEX_MAX_DURATION_SECONDS,
2020
retry: { maxAttempts: 3 },
2121
queue: {

‎apps/sim/connectors/google-workspace/company-crawl.test.ts‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -502,21 +502,24 @@ describe('Google Workspace per-user central crawl', () => {
502502
expect((await list(context(), undefined, CONFIG, 'google_calendar')).documents).toHaveLength(1)
503503
})
504504

505-
it('isolates explicit Calendar list access failures without claiming a disabled service', async () => {
506-
listUserDocuments.mockRejectedValueOnce(
507-
new GoogleApiError('calendar.events.list', 403, ['forbidden'])
508-
)
509-
const first = await list(context(), undefined, CONFIG, 'google_calendar')
510-
expect(first.listingFailures?.samples[0]).toEqual({
511-
scope: 'alice@corp.com',
512-
operation: 'calendar.events.list',
513-
status: 403,
514-
reasons: ['forbidden'],
515-
})
516-
const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar')
517-
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
518-
expect(second.reconciliationSafe).toBe(false)
519-
})
505+
it.each(['forbidden', 'notACalendarUser'])(
506+
'isolates explicit Calendar list access failures (%s) without claiming a disabled service',
507+
async (reason) => {
508+
listUserDocuments.mockRejectedValueOnce(
509+
new GoogleApiError('calendar.events.list', 403, [reason])
510+
)
511+
const first = await list(context(), undefined, CONFIG, 'google_calendar')
512+
expect(first.listingFailures?.samples[0]).toEqual({
513+
scope: 'alice@corp.com',
514+
operation: 'calendar.events.list',
515+
status: 403,
516+
reasons: [reason],
517+
})
518+
const second = await list(context(), first.nextCursor, CONFIG, 'google_calendar')
519+
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
520+
expect(second.reconciliationSafe).toBe(false)
521+
}
522+
)
520523

521524
it.each([{ error: { code: 403 } }, { error: { code: 403, errors: [], details: [] } }])(
522525
'propagates a Calendar 403 without reason codes: %j',
@@ -542,6 +545,9 @@ describe('Google Workspace per-user central crawl', () => {
542545
[403, ['domainPolicy']],
543546
[403, ['unrecognized-provider-code']],
544547
[403, ['forbidden', 'unrecognized-provider-code']],
548+
[403, ['notACalendarUser', 'unrecognized-provider-code']],
549+
[403, ['notACalendarUser', 'insufficientPermissions']],
550+
[403, ['notACalendarUser', 'rateLimitExceeded']],
545551
[401, ['authError']],
546552
[429, []],
547553
[500, ['backendError']],
@@ -571,6 +577,15 @@ describe('Google Workspace per-user central crawl', () => {
571577
await expect(list(context(), undefined, CONFIG, 'google_calendar')).rejects.toBe(error)
572578
})
573579

580+
it.each([
581+
new GoogleApiError('calendar.events.list', 403, ['notACalendarUser'], false),
582+
new GoogleApiError('calendar.calendarList.list', 403, ['notACalendarUser']),
583+
new GoogleApiError('calendar.events.list', 401, ['notACalendarUser']),
584+
])('does not isolate Calendar unavailability outside a complete list 403: %s', async (error) => {
585+
listUserDocuments.mockRejectedValueOnce(error)
586+
await expect(list(context(), undefined, CONFIG, 'google_calendar')).rejects.toBe(error)
587+
})
588+
574589
it('does not suppress delegation failures that resemble provider list failures', async () => {
575590
const ctx = context()
576591
const error = new GoogleApiError('gmail.threads.list', 400, ['failedPrecondition'])

‎apps/sim/connectors/google-workspace/company-crawl.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ function userListingFailure(
177177
: error.diagnostic.operation === 'calendar.events.list' &&
178178
error.status === 403 &&
179179
reasons.length > 0 &&
180-
reasons.every((reason) => reason === 'forbidden')
180+
reasons.every((reason) => reason === 'forbidden' || reason === 'notACalendarUser')
181181
return isolated
182182
? { operation: error.diagnostic.operation, status: error.status, reasons: [...reasons] }
183183
: null

‎apps/sim/lib/knowledge/application/search-source-overview.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock('@/lib/knowledge/read-access', () => ({
2424
}))
2525

2626
import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
27+
import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants'
2728

2829
const principal = { kind: 'session', userId: 'reader', sessionId: 'session' } as const
2930
const input = { organizationId: 'org-1', workspaceId: null }
@@ -36,6 +37,14 @@ const searchableProbeCount = () =>
3637
dbChainMockFns.limit.mock.calls.filter(([rows]) => rows === 1).length -
3738
AUTHORIZATION_SINGLE_ROW_READS
3839

40+
/** The configured-provider list is read once, before the batches, under the same bound. */
41+
const CONFIGURED_PROVIDER_READS = 1
42+
43+
/** Every other provider-bounded read in this use case is the indexing probe. */
44+
const indexingProbeCount = () =>
45+
dbChainMockFns.limit.mock.calls.filter(([rows]) => rows === MAX_SEARCH_SOURCE_PROVIDER_TYPES)
46+
.length - CONFIGURED_PROVIDER_READS
47+
3948
function yieldBatches(count: number) {
4049
mocks.batches.mockImplementation(async function* () {
4150
for (let index = 0; index < count; index += 1) yield sql`batch-${sql.raw(String(index))}`
@@ -73,4 +82,31 @@ describe('readSearchSourceOverview', () => {
7382
expect(result.hasSearchableDocuments).toBe(false)
7483
expect(searchableProbeCount()).toBe(3)
7584
})
85+
86+
it('stops probing for indexing once every configured provider type is known', async () => {
87+
yieldBatches(3)
88+
queueTableRows(member, [{ role: 'owner' }])
89+
queueTableRows(knowledgeConnector, [{ connectorType: 'gmail' }])
90+
queueTableRows(knowledgeConnector, [{ connectorType: 'gmail' }])
91+
92+
const result = await readSearchSourceOverview.execute({ principal, input })
93+
94+
expect(result.providers).toEqual([{ connectorType: 'gmail', isSyncing: true }])
95+
expect(indexingProbeCount()).toBe(1)
96+
})
97+
98+
it('keeps probing every batch while a configured provider type is still unaccounted for', async () => {
99+
yieldBatches(3)
100+
queueTableRows(member, [{ role: 'owner' }])
101+
queueTableRows(knowledgeConnector, [{ connectorType: 'gmail' }, { connectorType: 'notion' }])
102+
queueTableRows(knowledgeConnector, [{ connectorType: 'gmail' }])
103+
104+
const result = await readSearchSourceOverview.execute({ principal, input })
105+
106+
expect(result.providers).toEqual([
107+
{ connectorType: 'gmail', isSyncing: true },
108+
{ connectorType: 'notion', isSyncing: false },
109+
])
110+
expect(indexingProbeCount()).toBe(3)
111+
})
76112
})

‎apps/sim/lib/knowledge/application/search-source-overview.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,30 @@ export const readSearchSourceOverview = instrumentSourceOverviewUseCase(
117117
/** One searchable document is the whole answer, so later batches skip the probe entirely. */
118118
const probesSearchable: boolean = probesSources && !hasSearchableDocuments
119119
if (probesSearchable) searchableProbes += 1
120+
/**
121+
* A provider type is only read back as membership of `indexingTypes`, so once every
122+
* configured type is in the set no later batch can change the answer.
123+
*/
124+
const probesIndexing: boolean =
125+
probesSources && providers.some(({ connectorType }) => !indexingTypes.has(connectorType))
120126
/** Annotated so the searchable probe's guard does not infer through its own result. */
121127
const [indexing, searchable]: [{ connectorType: string }[], { id: string }[]] =
122128
await Promise.all([
123-
probesSources
129+
probesIndexing
124130
? measureSearchStage('source_overview.indexing', () =>
125131
configuredProvidersQuery()
126132
.where(
127133
and(
128134
configured,
129135
syncingEnabled,
136+
/**
137+
* The probe narrows the configured set the provider list came from, so a
138+
* type already found stays found; excluding it only drops repeated work.
139+
* An empty set adds no predicate rather than a no-op one.
140+
*/
141+
indexingTypes.size > 0
142+
? notInArray(knowledgeConnector.connectorType, [...indexingTypes])
143+
: undefined,
130144
or(
131145
inArray(knowledgeConnector.status, ['pending', 'syncing']),
132146
and(

‎apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,18 @@ import type {
1515
} from '@/lib/knowledge/connectors/partition-work'
1616
import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors'
1717
import { GoogleApiError } from '@/connectors/google-workspace/api-errors'
18+
import { listGoogleWorkspaceDocuments } from '@/connectors/google-workspace/company-crawl'
1819
import { googleCompanyUserContextSchema } from '@/connectors/google-workspace/company-work'
1920
import type { GoogleWorkspaceUser } from '@/connectors/google-workspace/users'
2021
import { ConnectorSourceError } from '@/connectors/source-error'
2122
import type { ConnectorConfig, ExternalDocument, ExternalListingFailures } from '@/connectors/types'
23+
import { memberDocumentId } from '@/connectors/utils'
2224

23-
const mocks = vi.hoisted(() => ({ directory: vi.fn() }))
25+
const mocks = vi.hoisted(() => ({ directory: vi.fn(), getUser: vi.fn() }))
2426
vi.mock('@/connectors/google-workspace/users', async (original) => ({
2527
...(await original<typeof import('@/connectors/google-workspace/users')>()),
2628
listGoogleWorkspaceUsers: mocks.directory,
29+
getGoogleWorkspaceUser: mocks.getUser,
2730
}))
2831

2932
const document: ExternalDocument = {
@@ -203,6 +206,7 @@ function fixture(provider = 'google_calendar', syncIntervalMinutes = 60) {
203206

204207
beforeEach(() => {
205208
mocks.directory.mockReset()
209+
mocks.getUser.mockReset().mockImplementation(async (_token: string, id: string) => user(id))
206210
})
207211

208212
describe('durable Google company user scheduling', () => {
@@ -269,6 +273,58 @@ describe('durable Google company user scheduling', () => {
269273
}
270274
)
271275

276+
it('continues past unavailable Calendar users without the unresolved-error pause and retries them later', async () => {
277+
mocks.directory.mockResolvedValue({ users: ['a', 'b', 'c', 'z'].map(user) })
278+
const f = fixture()
279+
const listUserDocuments = vi.fn<ConnectorConfig['listDocuments']>(
280+
async (_token, _config, _cursor, ctx) => ({
281+
documents: [{ ...document, externalId: memberDocumentId('event', ctx) }],
282+
hasMore: false,
283+
})
284+
)
285+
for (let i = 0; i < 3; i++) {
286+
listUserDocuments.mockRejectedValueOnce(
287+
new GoogleApiError('calendar.events.list', 403, ['notACalendarUser'])
288+
)
289+
}
290+
const syncContext = {
291+
mirrorsSourceAcls: true,
292+
getDelegatedAccessToken: vi.fn(async () => 'user-token'),
293+
}
294+
f.list.mockImplementation(async (accessToken, sourceConfig, cursor) =>
295+
listGoogleWorkspaceDocuments({
296+
provider: 'google_calendar',
297+
accessToken,
298+
sourceConfig,
299+
cursor,
300+
syncContext,
301+
listUserDocuments,
302+
})
303+
)
304+
305+
await f.step(5)
306+
307+
expect(f.rows.get('z:content')?.complete).toBe(true)
308+
expect(f.saved()).toMatchObject({ complete: false, unsafe: true, resumeAt: null })
309+
for (const id of ['a', 'b', 'c']) {
310+
expect(f.rows.get(`${id}:content`)).toMatchObject({
311+
complete: false,
312+
attempts: 1,
313+
retryAt: new Date('2026-09-17T01:00:00Z'),
314+
failure: { status: 403, reasons: ['notACalendarUser'] },
315+
})
316+
}
317+
318+
f.advance(60 * 60 * 1000)
319+
f.restart()
320+
await f.step(4)
321+
322+
for (const id of ['a', 'b', 'c']) {
323+
expect(f.rows.get(`${id}:content`)).toMatchObject({ complete: true, attempts: 0 })
324+
expect(f.rows.get(`${id}:content`)?.failure).toBeUndefined()
325+
}
326+
})
327+
272328
it('bounds a run of unresolved user errors rather than marking the tenant complete', async () => {
273329
mocks.directory.mockResolvedValue({ users: ['a', 'b', 'c', 'd'].map(user) })
274330
const f = fixture()

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ async function describeUnacceptedMemberSync(
161161
memberSyncStatus: knowledgeConnector.memberSyncStatus,
162162
nextMemberSyncAt: knowledgeConnector.nextMemberSyncAt,
163163
syncLockToken: knowledgeConnector.syncLockToken,
164+
memberSyncLockToken: knowledgeConnector.memberSyncLockToken,
164165
archivedAt: knowledgeConnector.archivedAt,
165166
deletedAt: knowledgeConnector.deletedAt,
166167
})
@@ -181,7 +182,14 @@ async function describeUnacceptedMemberSync(
181182
) {
182183
return 'The member sync schedule changed after this run was scheduled'
183184
}
184-
return 'A member sync is already queued or running for this connector'
185+
if (row.memberSyncLockToken) {
186+
return 'A member sync is already queued or running for this connector'
187+
}
188+
/**
189+
* No checked condition explains the refusal. Naming a cause here instead once hid a connector
190+
* that was refused on every attempt for days, because the reason read as ordinary contention.
191+
*/
192+
return 'The connector refused the claim while no lock or status explains it'
185193
}
186194

187195
/**

0 commit comments

Comments
 (0)