Skip to content

Commit 2f02cbb

Browse files
committed
fix(ashby): harden uploads and pagination
1 parent f0582c9 commit 2f02cbb

16 files changed

Lines changed: 272 additions & 68 deletions

File tree

apps/docs/content/docs/integrations/ashby.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,7 +1183,7 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
11831183
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
11841184
| `perPage` | number | No | Number of results per page \(default and max 100\). Ashby silently caps larger values rather than erroring. |
11851185
| `syncToken` | string | No | Opaque token from a prior sync to fetch only jobs changed since then. Ashby only returns a new syncToken on the last page, so drain moreDataAvailable/nextCursor before persisting it. |
1186-
| `status` | json | No | One job status or an array of statuses to include: Open, Closed, Archived, or Draft |
1186+
| `status` | array | No | One job status or an array of statuses to include: Open, Closed, Archived, or Draft |
11871187
| `createdAfter` | string | No | Only return jobs created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) |
11881188
| `openedAfter` | string | No | Only return jobs opened after this ISO 8601 timestamp |
11891189
| `openedBefore` | string | No | Only return jobs opened before this ISO 8601 timestamp |
@@ -1441,7 +1441,7 @@ Searches for candidates by name and/or email with AND logic. Results are limited
14411441

14421442
### Ashby Search Jobs
14431443

1444-
Searches Ashby jobs by title and/or requisition ID.
1444+
Searches Ashby jobs by title and/or requisition ID. Provide at least one of these filters.
14451445

14461446
#### Input
14471447

apps/sim/blocks/blocks/ashby.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,13 @@ describe('AshbyBlock', () => {
325325
expect(condition.value).toContain('list_jobs')
326326
})
327327

328+
it('maps the editor status selection to the tool array contract', () => {
329+
const result = AshbyBlock.tools.config.params!(
330+
buildParams('list_jobs', { jobStatus: 'Open' })
331+
)
332+
expect(result.status).toEqual(['Open'])
333+
})
334+
328335
it('maps the shared draft-posting switch to the endpoint-specific parameter', () => {
329336
const listParams = AshbyBlock.tools.config.params!(
330337
buildParams('list_jobs', { includeUnpublishedJobPostingIds: true })
@@ -340,6 +347,25 @@ describe('AshbyBlock', () => {
340347
})
341348
})
342349

350+
describe('expanded list controls', () => {
351+
it('exposes only provider-supported pagination and sync controls', () => {
352+
const cursor = AshbyBlock.subBlocks.find((s) => s.id === 'cursor')
353+
const cursorOperations = (cursor?.condition as { value: string[] }).value
354+
expect(cursorOperations).not.toContain('search_candidates')
355+
expect(cursorOperations).not.toContain('search_jobs')
356+
357+
const syncToken = AshbyBlock.subBlocks.find((s) => s.id === 'syncToken')
358+
const syncOperations = (syncToken?.condition as { value: string[] }).value
359+
expect(syncOperations).toContain('list_application_feedback')
360+
})
361+
362+
it('exposes archived interview plans in the editor', () => {
363+
const includeArchived = AshbyBlock.subBlocks.find((s) => s.id === 'includeArchived')
364+
const operations = (includeArchived?.condition as { value: string[] }).value
365+
expect(operations).toContain('list_interview_plans')
366+
})
367+
})
368+
343369
describe('alternate lookup identifiers', () => {
344370
it('does not require the Ashby UUID when an alternate lookup is supported', () => {
345371
const candidateId = AshbyBlock.subBlocks.find((s) => s.id === 'candidateId')

apps/sim/blocks/blocks/ashby.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,8 +854,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
854854
'list_application_feedback',
855855
'list_application_history',
856856
'list_interview_plans',
857-
'search_candidates',
858-
'search_jobs',
859857
],
860858
},
861859
mode: 'advanced',
@@ -906,6 +904,7 @@ Output only the ISO 8601 timestamp string, nothing else.`,
906904
'list_custom_fields',
907905
'list_offers',
908906
'list_jobs',
907+
'list_application_feedback',
909908
],
910909
},
911910
mode: 'advanced',
@@ -992,6 +991,7 @@ Output only the JSON array, nothing else.`,
992991
'list_departments',
993992
'list_custom_fields',
994993
'list_locations',
994+
'list_interview_plans',
995995
],
996996
},
997997
mode: 'advanced',
@@ -1428,7 +1428,7 @@ Output only the JSON array.`,
14281428
if (params.openingIdentifier) result.identifier = params.openingIdentifier
14291429
if (params.filterStatus) result.status = params.filterStatus
14301430
if (params.filterJobId) result.jobId = params.filterJobId
1431-
if (params.jobStatus) result.status = params.jobStatus
1431+
if (params.jobStatus) result.status = [params.jobStatus]
14321432
if (params.sendNotifications === 'true' || params.sendNotifications === true) {
14331433
result.sendNotifications = true
14341434
}

apps/sim/connectors/ashby/ashby.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,40 @@ describe('ashbyConnector', () => {
6363
'feedback unavailable'
6464
)
6565
})
66+
67+
it('continues through short non-final note pages beyond the former page ceiling', async () => {
68+
const fetchMock = vi.spyOn(globalThis, 'fetch')
69+
fetchMock.mockResolvedValueOnce(ashbyResponse({ id: 'c1', name: 'One', applicationIds: [] }))
70+
for (let page = 1; page <= 6; page++) {
71+
fetchMock.mockResolvedValueOnce(
72+
ashbyResponse(
73+
[{ content: `note-${page}`, createdAt: `2026-01-0${page}T00:00:00Z` }],
74+
page < 6 ? { moreDataAvailable: true, nextCursor: `cursor-${page}` } : {}
75+
)
76+
)
77+
}
78+
const document = await ashbyConnector.getDocument('key', {}, 'c1')
79+
expect(document?.content).toContain('note-6')
80+
expect(fetchMock).toHaveBeenCalledTimes(7)
81+
})
82+
83+
it('fails hydration on a repeated note cursor instead of storing partial content', async () => {
84+
vi.spyOn(globalThis, 'fetch')
85+
.mockResolvedValueOnce(ashbyResponse({ id: 'c1', name: 'One', applicationIds: [] }))
86+
.mockResolvedValueOnce(
87+
ashbyResponse([{ content: 'note-1' }], {
88+
moreDataAvailable: true,
89+
nextCursor: 'same-cursor',
90+
})
91+
)
92+
.mockResolvedValueOnce(
93+
ashbyResponse([{ content: 'note-2' }], {
94+
moreDataAvailable: true,
95+
nextCursor: 'same-cursor',
96+
})
97+
)
98+
await expect(ashbyConnector.getDocument('key', {}, 'c1')).rejects.toThrow(
99+
/repeated a pagination cursor/
100+
)
101+
})
66102
})

apps/sim/connectors/ashby/ashby.ts

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@ const MAX_DOCUMENT_CHARACTERS = 2_000_000
3131
*/
3232
const MAX_APPLICATIONS_FOR_FEEDBACK = 10
3333

34-
/**
35-
* Defensive page ceiling for the per-candidate note and feedback cursor loops. Ashby
36-
* terminates them via `moreDataAvailable`, but a repeated cursor would otherwise spin
37-
* forever inside a single `getDocument` call.
38-
*/
39-
const MAX_SUB_PAGES = 5
40-
4134
type UnknownRecord = Record<string, unknown>
4235

4336
/**
@@ -457,9 +450,9 @@ function candidateMetadata(candidate: AshbyCandidateSummary): Record<string, unk
457450
async function fetchAllNotes(accessToken: string, candidateId: string): Promise<AshbyNote[]> {
458451
const notes: AshbyNote[] = []
459452
let cursor: string | undefined
460-
let hasMore = true
453+
const seenCursors = new Set<string>()
461454

462-
for (let page = 0; hasMore && page < MAX_SUB_PAGES; page++) {
455+
while (notes.length < MAX_NOTES_PER_CANDIDATE) {
463456
const body: UnknownRecord = { candidateId, limit: NOTES_PER_PAGE }
464457
if (cursor) body.cursor = cursor
465458
const data = await ashbyPost(accessToken, 'candidate.listNotes', body)
@@ -468,16 +461,19 @@ async function fetchAllNotes(accessToken: string, candidateId: string): Promise<
468461
if (notes.length >= MAX_NOTES_PER_CANDIDATE) break
469462
notes.push(mapNote(raw))
470463
}
471-
cursor = data.nextCursor ?? undefined
472-
hasMore =
473-
notes.length < MAX_NOTES_PER_CANDIDATE && Boolean(data.moreDataAvailable) && Boolean(cursor)
474-
}
475-
476-
if (hasMore) {
477-
logger.warn('Stopped paginating Ashby candidate notes at the page ceiling', {
478-
candidateId,
479-
notes: notes.length,
480-
})
464+
if (!data.moreDataAvailable || notes.length >= MAX_NOTES_PER_CANDIDATE) break
465+
const nextCursor = data.nextCursor?.trim()
466+
if (!nextCursor) {
467+
throw new Error('Ashby candidate.listNotes reported more data without a next cursor')
468+
}
469+
if (seenCursors.has(nextCursor)) {
470+
throw new Error('Ashby candidate.listNotes repeated a pagination cursor')
471+
}
472+
if (results.length === 0) {
473+
throw new Error('Ashby candidate.listNotes returned an empty non-final page')
474+
}
475+
seenCursors.add(nextCursor)
476+
cursor = nextCursor
481477
}
482478

483479
return notes
@@ -493,9 +489,9 @@ async function fetchFeedbackForApplication(
493489
): Promise<AshbyFeedbackSummary[]> {
494490
const feedback: AshbyFeedbackSummary[] = []
495491
let cursor: string | undefined
496-
let hasMore = true
492+
const seenCursors = new Set<string>()
497493

498-
for (let page = 0; hasMore && page < MAX_SUB_PAGES; page++) {
494+
while (feedback.length < MAX_FEEDBACK_PER_CANDIDATE) {
499495
const body: UnknownRecord = { applicationId, limit: FEEDBACK_PER_PAGE }
500496
if (cursor) body.cursor = cursor
501497
const data = await ashbyPost(accessToken, 'applicationFeedback.list', body)
@@ -504,18 +500,19 @@ async function fetchFeedbackForApplication(
504500
if (feedback.length >= MAX_FEEDBACK_PER_CANDIDATE) break
505501
feedback.push(mapFeedback(raw))
506502
}
507-
cursor = data.nextCursor ?? undefined
508-
hasMore =
509-
feedback.length < MAX_FEEDBACK_PER_CANDIDATE &&
510-
Boolean(data.moreDataAvailable) &&
511-
Boolean(cursor)
512-
}
513-
514-
if (hasMore) {
515-
logger.warn('Stopped paginating Ashby application feedback at the page ceiling', {
516-
applicationId,
517-
submissions: feedback.length,
518-
})
503+
if (!data.moreDataAvailable || feedback.length >= MAX_FEEDBACK_PER_CANDIDATE) break
504+
const nextCursor = data.nextCursor?.trim()
505+
if (!nextCursor) {
506+
throw new Error('Ashby applicationFeedback.list reported more data without a next cursor')
507+
}
508+
if (seenCursors.has(nextCursor)) {
509+
throw new Error('Ashby applicationFeedback.list repeated a pagination cursor')
510+
}
511+
if (results.length === 0) {
512+
throw new Error('Ashby applicationFeedback.list returned an empty non-final page')
513+
}
514+
seenCursors.add(nextCursor)
515+
cursor = nextCursor
519516
}
520517

521518
return feedback

apps/sim/lib/internal/ashby/operations.test.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
56

67
const mocks = vi.hoisted(() => ({
78
assertToolFileAccess: vi.fn(),
8-
downloadFileFromStorage: vi.fn(),
9+
downloadServableFileFromStorage: vi.fn(),
910
secureFetchWithPinnedIP: vi.fn(),
1011
validateUrlWithDNS: vi.fn(),
1112
}))
@@ -14,14 +15,15 @@ vi.mock('@/app/api/files/authorization', () => ({
1415
assertToolFileAccess: mocks.assertToolFileAccess,
1516
}))
1617
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
17-
downloadFileFromStorage: mocks.downloadFileFromStorage,
18+
downloadServableFileFromStorage: mocks.downloadServableFileFromStorage,
1819
}))
1920
vi.mock('@/lib/core/security/input-validation.server', () => ({
2021
secureFetchWithPinnedIP: mocks.secureFetchWithPinnedIP,
2122
validateUrlWithDNS: mocks.validateUrlWithDNS,
2223
}))
2324

2425
import { executeAshbyUpload } from '@/lib/internal/ashby/operations'
26+
import { ashbyUploadInputSchema } from '@/lib/internal/ashby/schema'
2527

2628
const FILE = {
2729
id: 'file-1',
@@ -36,7 +38,10 @@ describe('executeAshbyUpload', () => {
3638
beforeEach(() => {
3739
vi.clearAllMocks()
3840
mocks.assertToolFileAccess.mockResolvedValue(null)
39-
mocks.downloadFileFromStorage.mockResolvedValue(Buffer.from('resume'))
41+
mocks.downloadServableFileFromStorage.mockResolvedValue({
42+
buffer: Buffer.from('resume'),
43+
contentType: 'application/pdf',
44+
})
4045
mocks.validateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
4146
mocks.secureFetchWithPinnedIP.mockResolvedValue({ ok: true, status: 204 })
4247
vi.stubGlobal(
@@ -77,7 +82,7 @@ describe('executeAshbyUpload', () => {
7782
output: { id: 'candidate-1' },
7883
})
7984
expect(mocks.assertToolFileAccess).toHaveBeenCalledOnce()
80-
expect(mocks.downloadFileFromStorage).toHaveBeenCalledOnce()
85+
expect(mocks.downloadServableFileFromStorage).toHaveBeenCalledOnce()
8186
expect(mocks.validateUrlWithDNS).toHaveBeenCalledWith(
8287
'https://uploads.example.com/form',
8388
'uploadUrl',
@@ -109,7 +114,65 @@ describe('executeAshbyUpload', () => {
109114
{ userId: 'sim-user', requestId: 'request-1' }
110115
)
111116
expect(response.status).toBe(404)
112-
expect(mocks.downloadFileFromStorage).not.toHaveBeenCalled()
117+
expect(mocks.downloadServableFileFromStorage).not.toHaveBeenCalled()
118+
expect(fetch).not.toHaveBeenCalled()
119+
})
120+
121+
it('parses advanced-mode JSON file inputs and trims the candidate ID at the boundary', () => {
122+
const parsed = ashbyUploadInputSchema.parse({
123+
apiKey: 'key',
124+
candidateId: ' candidate-1 ',
125+
file: JSON.stringify(FILE),
126+
})
127+
expect(parsed.candidateId).toBe('candidate-1')
128+
expect(parsed.file).toEqual(FILE)
129+
})
130+
131+
it('uploads the servable artifact bytes with their resolved content type', async () => {
132+
mocks.downloadServableFileFromStorage.mockResolvedValueOnce({
133+
buffer: Buffer.from('compiled-docx'),
134+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
135+
})
136+
const response = await executeAshbyUpload(
137+
{
138+
apiKey: 'key',
139+
candidateId: 'candidate-1',
140+
file: FILE,
141+
fileName: 'resume.docx',
142+
onBehalfOfUserId: null,
143+
},
144+
'resume',
145+
{ userId: 'sim-user', requestId: 'request-1' }
146+
)
147+
expect(response.status).toBe(200)
148+
const registrationBody = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))
149+
expect(registrationBody).toMatchObject({
150+
filename: 'resume.docx',
151+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
152+
contentLength: Buffer.byteLength('compiled-docx'),
153+
})
154+
})
155+
156+
it('returns 413 when the servable file exceeds Ashby upload limits', async () => {
157+
mocks.downloadServableFileFromStorage.mockRejectedValueOnce(
158+
new PayloadSizeLimitError({
159+
label: 'servable file download',
160+
maxBytes: 25 * 1024 * 1024,
161+
observedBytes: 25 * 1024 * 1024 + 1,
162+
})
163+
)
164+
const response = await executeAshbyUpload(
165+
{
166+
apiKey: 'key',
167+
candidateId: 'candidate-1',
168+
file: FILE,
169+
fileName: null,
170+
onBehalfOfUserId: null,
171+
},
172+
'file',
173+
{ userId: 'sim-user', requestId: 'request-1' }
174+
)
175+
expect(response.status).toBe(413)
113176
expect(fetch).not.toHaveBeenCalled()
114177
})
115178
})

0 commit comments

Comments
 (0)