Skip to content

Commit 5a88ce2

Browse files
authored
feat(ashby): incremental job sync, custom field writes, and application lifecycle ops (#6703)
* feat(tools): add incremental job sync and draft postings to Ashby reads list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, so a scheduled sync costs O(changed reqs) instead of rescanning every req. Ashby only returns the token once the last page is drained, which the param description states. The output is named as a cursor deliberately. It is an opaque resumption marker, not a credential, so it belongs with nextCursor - and a field literally named syncToken matches the /^.*token$/i deny-list in redaction and renders as [REDACTED], which makes an incremental sync unusable since the operator cannot read the value the next run needs. The wire name stays syncToken. list_job_postings gains includeUnpublishedJobPostings, plus the posting status field - without status a caller cannot tell a returned draft from a published posting, which makes the flag useless. Also widens the custom field valueLabel type, which MultiValueSelect returns as an array, for the write operations that follow. * fix(tools): render Ashby object-shaped API errors readably Ashby documents two error shapes and uses both. The `errors` array form carries `{ message, parameter }` objects, which stringified to '[object Object]' and hid the real cause - including the 403 a key gets when it lacks a module permission. Also adds the shared pieces the new write operations need: one definition of the custom field value shape for the read and write paths to agree on, and a normalizer for Ashby's case-sensitive objectType enum so a model emitting 'candidate' fails here with the allowed values rather than at the API. * feat(tools): add Ashby custom field writes, delete, source, and anonymize customField.setValue/setValues are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Writing null clears a value, so the annotation is reversible. Because null clears, every one of these operations requires explicit intent before it can destroy data. The block's required markers do not cover the agent path - a model calls the tool directly, so tools.config.params never runs and validateRequiredParametersAfterMerge skips a param marked not-required: - set_custom_field_value rejects an absent or blank fieldValue; an explicit null still clears - change_application_source requires unsetSource to clear, and rejects a source id and an unset request together, since preferring either one silently discards the other. Ashby has no 'leave unchanged' mode, so setting and clearing are the only two intents and exactly one must be expressed - set_custom_field_values rejects an empty array locally rather than relying on Ashby to reject it application.delete needs candidatesDelete, a module permission separate from candidatesWrite. candidate.anonymize strips PII but leaves the record; Ashby exposes no candidate deletion endpoint. * test(tools): cover the new Ashby request and response shapes Includes a gated live harness (ASHBY_LIVE=1) alongside the mocked tests. vitest.setup.ts stubs global fetch for every file in the app, so the live file restores the real implementation and asserts the restore worked - without that guard the whole suite silently passes against a mock. * feat(blocks): expose the new Ashby operations in the block fieldValue is polymorphic (boolean, number, string, array, object, null), so it decodes structured input and otherwise passes text through. The decoding is deliberately narrow rather than a blanket JSON.parse, which corrupts real text: 1e999 becomes Infinity and serializes back out as null, which CLEARS the field; a long numeric id loses precision past 2^53; and prose starting with { turns into an object. Only the literal keywords, {, [ or " prefixes, and exactly round-tripping numbers decode. fieldValue carries no wand generationType: json-object forces braces and json-array forces brackets, and both would wrap a value that must stay bare. fieldValues, whose contract really is an array, uses json-array. Setting and clearing an application source are mutually exclusive, so the Source ID field is conditioned off while the clear switch is on and the params mapping sends only the intent the switch selects. A value typed before the switch was flipped cannot reach the tool and surface as an error with no visible cause. * docs(ashby): document the new operations, permissions, and limitations Ashby scopes permissions per module and they fail at runtime, not build time, so the block docs now carry the permission table. Also records the hard API limits worth designing around: no note or tag on a job, no pagination on jobPosting.list, and no delete for jobs, candidates, or custom field definitions. * fix(blocks): stop a stale create-path source id leaking into a source change The executor merges { ...inputs, ...transformedParams }, so any key the params mapping leaves unset inherits whatever inputs held. The shared create-path sourceId subblock reaches inputs even on change_application_source: it is mode 'advanced', and the serializer includes an advanced subblock whenever its value is non-empty without ever evaluating its condition (serializer/index.ts). So a source id typed while on Create Application survived into a source change. With both fields blank it silently attributed a source nobody asked for, and with the clear switch on it collided with the unset request and failed with no visible cause, because the field producing it is hidden in that state. sourceId is now always assigned for this operation rather than conditionally, so it can never inherit. The regression test asserts the merged result rather than the mapping alone, since the gap between them is where the bug lived.
1 parent 3d4e3d2 commit 5a88ce2

21 files changed

Lines changed: 2326 additions & 43 deletions

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

Lines changed: 272 additions & 12 deletions
Large diffs are not rendered by default.

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

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,252 @@ describe('AshbyBlock', () => {
7878
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
7979
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
8080
})
81+
82+
it('does not force braces or brackets on the polymorphic fieldValue', () => {
83+
// fieldValue legitimately takes a bare boolean, number, string, or null,
84+
// so neither the 'json-object' nor the 'json-array' reinforcement applies -
85+
// both would make the wand emit a wrapper the field must not receive.
86+
const fieldValue = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValue')
87+
expect(fieldValue?.wandConfig?.enabled).toBe(true)
88+
expect(fieldValue?.wandConfig?.generationType).toBeUndefined()
89+
})
90+
91+
it('requests array output for fieldValues, whose contract is a JSON array', () => {
92+
const fieldValues = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValues')
93+
expect(fieldValues?.wandConfig?.generationType).toBe('json-array')
94+
})
95+
})
96+
97+
describe('fieldValue parsing (set_custom_field_value)', () => {
98+
const parse = (fieldValue: unknown) =>
99+
AshbyBlock.tools.config.params!(buildParams('set_custom_field_value', { fieldValue }))
100+
.fieldValue
101+
102+
it('decodes null so the annotation can be cleared', () => {
103+
// Ashby clears a custom field when it receives an explicit null, which is
104+
// what makes a written annotation reversible.
105+
expect(parse('null')).toBeNull()
106+
})
107+
108+
it('decodes booleans and numbers for Boolean and Number fields', () => {
109+
expect(parse('true')).toBe(true)
110+
expect(parse('42')).toBe(42)
111+
})
112+
113+
it('decodes a JSON array for MultiValueSelect fields', () => {
114+
expect(parse('["Remote","Hybrid"]')).toEqual(['Remote', 'Hybrid'])
115+
})
116+
117+
it('decodes a JSON object for Currency and range fields', () => {
118+
expect(parse('{"value":150000,"currencyCode":"USD"}')).toEqual({
119+
value: 150000,
120+
currencyCode: 'USD',
121+
})
122+
})
123+
124+
it('passes unparseable text through as a plain string', () => {
125+
// A bare option name is the most common input for String, LongText, and
126+
// ValueSelect fields, so it must not be rejected as invalid JSON.
127+
expect(parse('Senior Engineer')).toBe('Senior Engineer')
128+
})
129+
130+
it('decodes a quoted numeric string back to a string', () => {
131+
// The escape hatch for a String field whose value looks like a number.
132+
expect(parse('"123"')).toBe('123')
133+
})
134+
135+
it('does not let an overflowing number become a field clear', () => {
136+
// 1e999 parses to Infinity, which JSON.stringify emits as null - and null
137+
// clears the field. The user typed a number, not a clear.
138+
expect(parse('1e999')).toBe('1e999')
139+
})
140+
141+
it('does not silently lose precision on long numeric ids', () => {
142+
expect(parse('12345678901234567890')).toBe('12345678901234567890')
143+
expect(parse('0123')).toBe('0123')
144+
})
145+
146+
it('leaves prose that merely starts like JSON alone when it does not parse', () => {
147+
expect(parse('{not really json')).toBe('{not really json')
148+
})
149+
150+
it('passes an already-parsed value through untouched', () => {
151+
// An upstream block reference resolves to a real value, not to text.
152+
expect(parse({ value: 1 })).toEqual({ value: 1 })
153+
expect(parse(false)).toBe(false)
154+
})
155+
156+
it('leaves fieldValue alone for other operations', () => {
157+
const result = AshbyBlock.tools.config.params!(
158+
buildParams('list_jobs', { fieldValue: 'Senior Engineer' })
159+
)
160+
expect(result.fieldValue).toBeUndefined()
161+
})
162+
})
163+
164+
describe('fieldValues parsing (set_custom_field_values)', () => {
165+
it('maps the fieldValues subBlock onto the tool’s values param', () => {
166+
const result = AshbyBlock.tools.config.params!(
167+
buildParams('set_custom_field_values', {
168+
fieldValues: '[{"fieldId":"abc","fieldValue":"High"}]',
169+
})
170+
)
171+
expect(result.values).toEqual([{ fieldId: 'abc', fieldValue: 'High' }])
172+
expect(result.fieldValues).toBeUndefined()
173+
})
174+
175+
it('throws instead of silently dropping the writes when the JSON is malformed', () => {
176+
expect(() =>
177+
AshbyBlock.tools.config.params!(
178+
buildParams('set_custom_field_values', { fieldValues: 'not json' })
179+
)
180+
).toThrow(/Invalid JSON in Ashby custom field values/)
181+
})
182+
183+
it('throws when the parsed JSON is not an array', () => {
184+
expect(() =>
185+
AshbyBlock.tools.config.params!(
186+
buildParams('set_custom_field_values', { fieldValues: '{"fieldId":"abc"}' })
187+
)
188+
).toThrow(/expected a JSON array/)
189+
})
190+
})
191+
192+
describe('change_application_source', () => {
193+
it('emits sourceId as undefined when the field is left blank', () => {
194+
// The key must be PRESENT and undefined, not absent. The executor merges
195+
// `{ ...inputs, ...transformedParams }`, so an absent key inherits whatever
196+
// inputs held - which is exactly how a stale create-path sourceId used to
197+
// leak in. Presence is what overrides it.
198+
const result = AshbyBlock.tools.config.params!(
199+
buildParams('change_application_source', { applicationId: 'app-1', changeSourceId: '' })
200+
)
201+
expect(result).toHaveProperty('sourceId')
202+
expect(result.sourceId).toBeUndefined()
203+
expect(result).not.toHaveProperty('unsetSource')
204+
})
205+
206+
it('passes unsetSource through only when the switch is on', () => {
207+
const result = AshbyBlock.tools.config.params!(
208+
buildParams('change_application_source', { changeSourceId: '', unsetSource: 'true' })
209+
)
210+
expect(result.unsetSource).toBe(true)
211+
})
212+
213+
it('never sends a stale source id alongside a clear request', () => {
214+
// The Source ID field is hidden once the clear switch is on, but a value
215+
// typed beforehand is still stored. Sending both would trip the tool's
216+
// exclusivity guard and surface as an error the user cannot see the cause of.
217+
const result = AshbyBlock.tools.config.params!(
218+
buildParams('change_application_source', {
219+
changeSourceId: 'src-left-over',
220+
unsetSource: 'true',
221+
})
222+
)
223+
expect(result.unsetSource).toBe(true)
224+
expect(result).toHaveProperty('sourceId')
225+
expect(result.sourceId).toBeUndefined()
226+
})
227+
228+
it('never inherits a stale create-path source id through the executor merge', () => {
229+
// The executor runs `{ ...inputs, ...transformedParams }`, so any key this
230+
// mapping leaves unset inherits whatever inputs held. The shared
231+
// create-path `sourceId` subblock reaches inputs even on this operation:
232+
// it is mode 'advanced', and the serializer includes an advanced subblock
233+
// on a non-empty value without evaluating its condition. Assert the merged
234+
// result, not just the mapping, since that gap is where the bug lived.
235+
const merge = (inputs: Record<string, unknown>) => ({
236+
...inputs,
237+
...AshbyBlock.tools.config.params!(inputs),
238+
})
239+
240+
const cleared = merge(
241+
buildParams('change_application_source', {
242+
applicationId: 'app-1',
243+
sourceId: 'stale-from-create-application',
244+
changeSourceId: '',
245+
unsetSource: 'true',
246+
})
247+
)
248+
expect(cleared.sourceId).toBeUndefined()
249+
expect(cleared.unsetSource).toBe(true)
250+
251+
const untouched = merge(
252+
buildParams('change_application_source', {
253+
applicationId: 'app-1',
254+
sourceId: 'stale-from-create-application',
255+
changeSourceId: '',
256+
})
257+
)
258+
expect(untouched.sourceId).toBeUndefined()
259+
260+
const explicit = merge(
261+
buildParams('change_application_source', {
262+
applicationId: 'app-1',
263+
sourceId: 'stale-from-create-application',
264+
changeSourceId: 'src-intended',
265+
})
266+
)
267+
expect(explicit.sourceId).toBe('src-intended')
268+
})
269+
270+
it('hides the source id field while the clear switch is on', () => {
271+
const sourceField = AshbyBlock.subBlocks.find((s) => s.id === 'changeSourceId')
272+
const condition = sourceField?.condition as { and?: { field: string; not?: boolean } }
273+
expect(condition.and).toEqual({ field: 'unsetSource', value: true, not: true })
274+
})
275+
276+
it('maps a provided source id onto sourceId', () => {
277+
const result = AshbyBlock.tools.config.params!(
278+
buildParams('change_application_source', { changeSourceId: 'src-1' })
279+
)
280+
expect(result.sourceId).toBe('src-1')
281+
})
282+
283+
it('does not emit a null sourceId for other operations', () => {
284+
// create_candidate treats an absent source as "no source", so a null here
285+
// would turn an omitted optional field into an explicit write.
286+
const result = AshbyBlock.tools.config.params!(buildParams('create_candidate', {}))
287+
expect(result).not.toHaveProperty('sourceId')
288+
})
289+
})
290+
291+
describe('operation and tool registration stay in sync', () => {
292+
it('has a matching ashby_<operation> tool in access for every dropdown option', () => {
293+
// tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown
294+
// option without a matching tool id resolves to a tool that does not exist.
295+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
296+
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
297+
const access = new Set(AshbyBlock.tools.access)
298+
const missing = optionIds.filter((id) => !access.has(`ashby_${id}`))
299+
expect(missing).toEqual([])
300+
})
301+
302+
it('has a dropdown option for every tool listed in access', () => {
303+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
304+
const optionIds = new Set(
305+
(operation?.options as Array<{ id: string }>).map((o) => `ashby_${o.id}`)
306+
)
307+
const unreachable = AshbyBlock.tools.access!.filter((id) => !optionIds.has(id))
308+
expect(unreachable).toEqual([])
309+
})
310+
311+
it('has a canvas sentence for every dropdown option', () => {
312+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
313+
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
314+
const sentences = AshbyBlock.canvasPresentation?.sentences?.byOperation ?? {}
315+
const missing = optionIds.filter((id) => !(id in sentences))
316+
expect(missing).toEqual([])
317+
})
318+
})
319+
320+
describe('list_jobs incremental sync', () => {
321+
it('offers the syncToken field on list_jobs', () => {
322+
// Without a sync token every scheduled run rescans the full req set.
323+
const syncToken = AshbyBlock.subBlocks.find((s) => s.id === 'syncToken')
324+
const condition = syncToken?.condition as { value: string[] }
325+
expect(condition.value).toContain('list_jobs')
326+
})
81327
})
82328

83329
describe('list_applications candidateId filter', () => {

0 commit comments

Comments
 (0)