Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ export function Person({ organizationId, memberId }: PersonProps) {
const [editing, setEditing] = useState(false)
const routes = organizationRoutes(organizationId)
const originQuery = serializePeopleView({ ...origin, view: origin.view ?? 'directory' })
const data = query.isError ? undefined : query.data
const notFound = isApiClientError(query.error) && query.error.status === 404
const refused = isApiClientError(query.error) && query.error.status < 500
// A transient refresh failure keeps the loaded profile and any edit open on it; a refusal does not.
const loadError = query.data && !refused ? null : query.error
const data = loadError ? undefined : query.data

const chartHref = serializePeopleView(routes.people, {
...origin,
Expand Down Expand Up @@ -129,14 +133,14 @@ export function Person({ organizationId, memberId }: PersonProps) {
) : undefined
}
>
{query.error ? (
isApiClientError(query.error) && query.error.status === 404 ? (
{loadError ? (
notFound ? (
<SettingsEmptyState variant='inline'>
This person isn't in your organization.
</SettingsEmptyState>
) : (
<SettingsQueryErrorState
error={query.error}
error={loadError}
fallback='Could not load this person'
isRetrying={query.isFetching}
onRetry={() => void query.refetch()}
Expand Down
11 changes: 5 additions & 6 deletions apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type PeopleSyncStatus,
peopleSyncConfigSchema,
} from '@/lib/api/contracts/people-sync'
import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
import { organizationRoutes } from '@/lib/navigation/paths'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { useScimConnection } from '@/ee/scim/hooks/scim'
Expand Down Expand Up @@ -45,7 +46,9 @@ export function DirectorySyncForm({
const [tenantId, setTenantId] = useState(current?.tenantId ?? '')
const [clientId, setClientId] = useState(current?.clientId ?? '')
const [secret, setSecret] = useState('')
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
const [fieldErrors, setFieldErrors] = useState<
Partial<Record<'domain' | 'token' | 'tenantId' | 'clientId' | 'clientSecret', string>>
>({})
const { settingsFeatures } = useOrganizationContext()
const provisioning = useScimConnection(organizationId, source === 'scim')
const scimReady = provisioning.isSuccess && provisioning.data.connection?.status === 'active'
Expand All @@ -66,11 +69,7 @@ export function DirectorySyncForm({
: { source }
)
if (!config.success) {
setFieldErrors(
Object.fromEntries(
config.error.issues.map((issue) => [String(issue.path[0]), issue.message])
)
)
setFieldErrors(flattenFieldErrors(config.error))
return
}
setFieldErrors({})
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/app/o/[organizationId]/people/people-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: Pe
const { focus, page } = origin
const size = useIsMobile() ? 1 : 4
const chart = usePeopleChart(organizationId, focus, Math.max(1, Math.min(page, 1_000_000)), size)
const branch = chart.isError ? undefined : chart.data
const notFound = isApiClientError(chart.error) && chart.error.status === 404
const refused = isApiClientError(chart.error) && chart.error.status < 500
// A transient refresh failure keeps the chart on screen; a refusal replaces it.
const branch = refused ? undefined : chart.data
const profileQuery = serializePeopleView({
...origin,
view: 'chart',
Expand Down Expand Up @@ -264,7 +267,7 @@ export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: Pe
</div>
</>
) : chart.error ? (
isApiClientError(chart.error) && chart.error.status === 404 ? (
notFound ? (
<EmptyState
title='Person unavailable'
description='This person is no longer in your organization’s directory.'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export function PeopleDirectory({ organizationId }: PeopleProps) {
rows={rows}
onRowClick={(memberId) => router.push(profileHref(memberId))}
onLoadMore={() => void people.fetchNextPage()}
hasMore={people.hasNextPage}
hasMore={people.hasNextPage && !changingFilter}
isLoadingMore={people.isFetchingNextPage}
isFetching={people.isFetching}
loadMoreFailed={people.isFetchNextPageError}
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/o/[organizationId]/search/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ function OrganizationSearchContent({ userId, chatId }: OrganizationSearchProps)
const { recordQuery } = useSearchHistoryActions()
const router = useRouter()
const pathname = usePathname()
const hasSearchParams = useSearchParams().size > 0
const hasSearchParams = useSearchParams().toString() !== ''
const routes = organizationRoutes(organization.id)
const hasConversation = Boolean(chatId) || pathname.startsWith(`${routes.search}/chat/`)
const queryClient = useQueryClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ describe('evidence-linked citations', () => {
true
)
expect(persisted.sources).toEqual(live.sources)
const disguised = {
...webOutput,
results: [{ ...webOutput.results[0], url: 'https://docs.example.test@attacker.test/cli' }],
}
expect(
resolveMessageCitations(webBlocks('success', true, disguised), '', true).sources
).toEqual([])
expect(
resolveMessageCitations(webBlocks('error', false, webOutput), '', true).blocks[1].content
).toBe('Install it ')
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/mcp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ export function categorizeMcpServerError(
}

/**
* Enhanced error categorization for more specific HTTP status codes.
* Returns safe, generic messages to prevent leaking internal details.
* HTTP status and safe, generic message for any error: Sim's own MCP error classes
* first, then message matching for SDK and third-party errors.
*/
export function categorizeError(error: unknown): { message: string; status: number } {
if (!(error instanceof Error)) {
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/mothership/chat/citation-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ type RetrievalCitationSource = {
snippet?: string
}

/** Tools whose successful results supply sources: connected-source retrieval and public web research. */
const WEB_TOOLS = new Set(['web_search', 'web_fetch'])
/** Tools whose successful results supply sources: connected-source retrieval and public web research. */
const CITATION_TOOLS = new Set(['search_workspace', 'read_document', ...WEB_TOOLS])

/** Whether a successful result of this tool may supply source destinations. */
Expand Down Expand Up @@ -80,7 +80,9 @@ export function collectRetrievalCitationEvidence(blocks: readonly RetrievalCitat
continue
try {
const url = new URL(result.citationUrl)
// A web page can steer a fetched URL, so `https://trusted.example@other.example` never cites.
if (url.protocol !== 'https:' && url.protocol !== 'http:') continue
if (url.username || url.password) continue
} catch {
continue
}
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/lib/people/sync/entra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ const reportsResponse = z.object({
'@odata.nextLink': z.string().min(1).max(MAX_NEXT_LINK_LENGTH).optional(),
})

const deletedUserResponse = z.object({
error: z.object({ code: z.literal('Request_ResourceNotFound') }),
})

/** A user deleted after the users phase has no reports left to read; any other 404 still fails. */
function isDeletedUser(status: number, body: unknown) {
return status === 404 && deletedUserResponse.safeParse(body).success
}

/** App-only Graph tokens cannot read /manager; each user's /directReports supports User.Read.All. */
async function readReportPages(
items: readonly DirectoryReportRequest[],
Expand Down Expand Up @@ -101,6 +110,8 @@ async function readReportPages(
const timing = { get: (name: string) => headers.get(name) ?? null }
const retryAfterMs = directoryRetryDelay(response.status, timing)
if (retryAfterMs !== null) return { managerId: page.managerId, retryAfterMs }
if (isDeletedUser(response.status, response.body))
return { managerId: page.managerId, reportIds: [], next: null }
requireDirectorySuccess(response.status, timing)
const parsedReports = reportsResponse.safeParse(response.body)
if (!parsedReports.success)
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/people/sync/large-directory.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ it('rotates credentials without bypassing a provider cooldown or publishing the
expect(rows).toEqual([{ id: director, title: 'President' }])
})

it('resumes a large Entra hierarchy without replaying successful batch siblings or creating accounts', async () => {
it('resumes a large Entra hierarchy past a user deleted mid-read without replaying successful batch siblings or creating accounts', async () => {
const total = 10007
const tenantId = generateId()
const graphConfig = {
Expand Down Expand Up @@ -378,6 +378,12 @@ it('resumes a large Entra hierarchy without replaying successful batch siblings
body: { error: graphConfig.clientSecret },
}
}
if (managerId === employee(2))
return {
id: request.id,
status: 404,
body: { error: { code: 'Request_ResourceNotFound' } },
}
if (managerId !== lead) return { id: request.id, status: 200, body: { value: [] } }
const offset = Number(target.searchParams.get('$skiptoken') || 0)
const length = Math.min(1000, total - 1 - offset)
Expand Down
39 changes: 22 additions & 17 deletions apps/sim/lib/people/sync/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { peopleDirectoryScimChange as change, peopleDirectorySync as sync } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { chunkArray } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { backoffWithJitter } from '@sim/utils/retry'
import { and, eq, isNotNull, isNull, lte, or, sql } from 'drizzle-orm'
Expand Down Expand Up @@ -40,7 +41,7 @@ export const CLEARED_RUN = {
cursor: null,
usersRead: 0,
} as const
const RELEASED = { leaseToken: null, leaseExpiresAt: null } as const
export const RELEASED = { leaseToken: null, leaseExpiresAt: null } as const
const available = (at: Date) => or(isNull(sync.leaseToken), lte(sync.leaseExpiresAt, at))
const due = (at: Date) => or(lte(sync.nextSyncAt, at), isNotNull(pendingScimChange))
type Connection = typeof sync.$inferSelect
Expand Down Expand Up @@ -78,11 +79,11 @@ const ownsLease = (worker: Worker) =>
/** Every page and its next cursor commit together, after checking that the same run still owns the source. */
async function checkpoint(
worker: Worker,
step: 'page' | 'publish',
write: (tx: DbTransaction, row: Checkpoint) => Promise<void>
write: (tx: DbTransaction, row: Checkpoint) => Promise<void>,
options: { lockProfiles?: boolean } = {}
) {
return db.transaction(async (tx) => {
if (step === 'publish') await lockProfiles(tx, worker.organizationId)
if (options.lockProfiles) await lockProfiles(tx, worker.organizationId)
const [row] = await tx
.select(checkpointColumns)
.from(sync)
Expand Down Expand Up @@ -127,7 +128,7 @@ function retryUpdate(row: Checkpoint, providerDelay: number, reason: string) {

async function checkpointUsers(worker: Worker, reader: DirectoryReader, cursor: string | null) {
const page = await reader.users(cursor)
return checkpoint(worker, 'page', async (tx) => {
return checkpoint(worker, async (tx) => {
await stageUsers(tx, worker, cursor, page.people)
await tx
.update(sync)
Expand All @@ -147,7 +148,7 @@ async function checkpointReports(worker: Worker, reader: DirectoryReader) {
throw new OrchestrationError('validation', 'The directory cannot read reporting lines.')
const items = await pendingManagers(db, worker)
if (!items.length) {
return checkpoint(worker, 'page', async (tx) => {
return checkpoint(worker, async (tx) => {
await tx.update(sync).set({ phase: 'publish' }).where(ownsLease(worker))
})
}
Expand All @@ -168,7 +169,7 @@ async function checkpointReports(worker: Worker, reader: DirectoryReader) {
const delays = results.flatMap((result) =>
'retryAfterMs' in result ? [result.retryAfterMs] : []
)
const applied = await checkpoint(worker, 'page', async (tx, row) => {
const applied = await checkpoint(worker, async (tx, row) => {
await stageRelationships(tx, worker, pages)
const update = delays.length
? retryUpdate(
Expand All @@ -184,12 +185,16 @@ async function checkpointReports(worker: Worker, reader: DirectoryReader) {

/** A local SCIM generation captures, validates, and publishes atomically. New provisioning changes have their own marker. */
async function runScim(worker: Worker) {
await checkpoint(worker, 'publish', async (tx, row) => {
await discardStage(tx, row.organizationId)
const count = await stageScimDirectory(tx, row.organizationId, worker.runId, row.sourceTarget)
await tx.update(sync).set({ phase: 'publish', usersRead: count }).where(ownsLease(worker))
await publish(tx, worker, 'scim')
})
await checkpoint(
worker,
async (tx, row) => {
await discardStage(tx, row.organizationId)
const count = await stageScimDirectory(tx, row.organizationId, worker.runId, row.sourceTarget)
await tx.update(sync).set({ phase: 'publish', usersRead: count }).where(ownsLease(worker))
await publish(tx, worker, 'scim')
},
{ lockProfiles: true }
)
}

async function handleFailure(worker: Worker, error: unknown) {
Expand All @@ -203,7 +208,7 @@ async function handleFailure(worker: Worker, error: unknown) {
retry || (error instanceof OrchestrationError && error.code !== 'internal')
? error.message
: 'Directory sync was interrupted. Sim will retry automatically.'
const applied = await checkpoint(worker, 'page', async (tx, row) => {
const applied = await checkpoint(worker, async (tx, row) => {
if (temporary) {
await tx
.update(sync)
Expand Down Expand Up @@ -297,7 +302,7 @@ export async function runDirectorySync(organizationId: string, trigger: keyof ty
if (!current) return true
await requirePeopleEnabled(organizationId)
if (current.phase === 'publish') {
await checkpoint(worker, 'publish', (tx) => publish(tx, worker, row.source))
await checkpoint(worker, (tx) => publish(tx, worker, row.source), { lockProfiles: true })
return true
}
reader ??= await connectReader(row)
Expand Down Expand Up @@ -340,8 +345,8 @@ export async function syncDueDirectories() {
SELECT organization_id AS "organizationId" FROM candidates
GROUP BY organization_id ORDER BY min(due_at), organization_id LIMIT 50
`)
for (let i = 0; i < connections.length && Date.now() < deadline; i += 2) {
const slice = connections.slice(i, i + 2)
for (const slice of chunkArray(connections, 2)) {
if (Date.now() >= deadline) break
const results = await Promise.allSettled(
slice.map(({ organizationId }) => runDirectorySync(organizationId, 'cron'))
)
Expand Down
5 changes: 2 additions & 3 deletions apps/sim/lib/people/sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
import { encryptSecret } from '@/lib/core/security/encryption'
import { oktaDirectoryHost } from '@/lib/people/provider-import'
import { lockProfiles } from '@/lib/people/repository'
import { CLEARED_RUN, runDirectorySync } from '@/lib/people/sync/runner'
import { CLEARED_RUN, RELEASED, runDirectorySync } from '@/lib/people/sync/runner'
import { pendingScimChange } from '@/lib/people/sync/scim-changes'
import { discardStage } from '@/lib/people/sync/stage'
import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement'
Expand Down Expand Up @@ -132,10 +132,9 @@ export async function configureDirectorySync(
existing && existing.retryCount > 0 && existing.nextSyncAt.getTime() > Date.now()
const values = {
...CLEARED_RUN,
...RELEASED,
...stored,
configuredBy: actorUserId,
leaseToken: null,
leaseExpiresAt: null,
nextSyncAt: coolingDown ? existing.nextSyncAt : new Date(),
retryCount: coolingDown ? existing.retryCount : 0,
lastError: coolingDown ? existing.lastError : null,
Expand Down
18 changes: 12 additions & 6 deletions apps/sim/lib/people/sync/stage.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@ it('refuses cyclic components, including cycles outside otherwise publishable tr
await expect(db.transaction(publish)).rejects.toThrow(/loop|cycle/i)
expect((await published()).map((p) => p.externalId)).toEqual(['root'])
})
it('resumes relationship pages from stored continuations and refuses incomplete or contradictory Graph data', async () => {
it('resumes relationship pages from stored continuations, refuses incomplete Graph data, and keeps the latest manager read', async () => {
await db
.update(peopleDirectorySync)
.set({ source: 'entra' })
.where(eq(peopleDirectorySync.organizationId, run.organizationId))
await writeUsers(null, [person('lead'), person('worker'), person('other')])
await writeUsers(null, [person('lead'), person('worker'), person('other'), person('peer')])
await expect(db.transaction((tx) => publishStagedSnapshot(tx, run, 'entra'))).rejects.toThrow(
/relationship|report/i
)
Expand All @@ -123,12 +123,18 @@ it('resumes relationship pages from stored continuations and refuses incomplete
cursor: 'continuation',
})
await record('lead', 'continuation', ['worker'], null)
await expect(record('other', null, ['worker'], null)).rejects.toThrow(/manager/i)
await record('other', null, [], null)
await record('worker', null, [], null)
await record('other', null, ['worker'], null)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
await db.transaction((tx) =>
stageRelationships(tx, run, [
{ managerId: 'worker', cursor: null, reportIds: ['other'], nextCursor: null },
{ managerId: 'peer', cursor: null, reportIds: ['other'], nextCursor: null },
])
)
expect(await pendingManagers(db, run)).toEqual([])
await db.transaction((tx) => publishStagedSnapshot(tx, run, 'entra'))
expect((await published()).find((r) => r.externalId === 'worker')?.managerExternalId).toBe('lead')
const managers = new Map((await published()).map((r) => [r.externalId, r.managerExternalId]))
expect(managers.get('worker')).toBe('other')
expect(managers.get('other')).toBe('peer')
})
it('refuses to publish a replaced generation', async () => {
await writeUsers(null, [person('live')])
Expand Down
Loading