diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ef1aa5fc34e..201719f0f00 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.11-alpine +FROM oven/bun:1.3.13-alpine # Install necessary packages for development RUN apk add --no-cache \ diff --git a/.github/workflows/docs-embeddings.yml b/.github/workflows/docs-embeddings.yml index 3e4de08e19f..c59380f19fb 100644 --- a/.github/workflows/docs-embeddings.yml +++ b/.github/workflows/docs-embeddings.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 2eab817d009..d0ea236373f 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Cache Bun dependencies uses: actions/cache@v4 @@ -122,7 +122,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Cache Bun dependencies uses: actions/cache@v4 diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 245023ab86f..f1078e5aa21 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Cache Bun dependencies uses: actions/cache@v4 diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ceb124c8230..466ae0423bf 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 diff --git a/.github/workflows/publish-ts-sdk.yml b/.github/workflows/publish-ts-sdk.yml index 1032ce7442a..d8f95242b86 100644 --- a/.github/workflows/publish-ts-sdk.yml +++ b/.github/workflows/publish-ts-sdk.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 8bcd240011c..2164aebfa49 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.13 - name: Setup Node uses: actions/setup-node@v4 diff --git a/apps/docs/content/docs/en/tools/ashby.mdx b/apps/docs/content/docs/en/tools/ashby.mdx index 138368e36c6..f75589fa9c4 100644 --- a/apps/docs/content/docs/en/tools/ashby.mdx +++ b/apps/docs/content/docs/en/tools/ashby.mdx @@ -38,7 +38,7 @@ Integrate Ashby into the workflow. Manage candidates (list, get, create, update, ### `ashby_add_candidate_tag` -Adds a tag to a candidate in Ashby. +Adds a tag to a candidate in Ashby and returns the updated candidate. #### Input @@ -52,7 +52,37 @@ Adds a tag to a candidate in Ashby. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Whether the tag was successfully added | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_change_application_stage` @@ -71,8 +101,37 @@ Moves an application to a different interview stage. Requires an archive reason | Parameter | Type | Description | | --------- | ---- | ----------- | -| `applicationId` | string | Application UUID | -| `stageId` | string | New interview stage UUID | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_create_application` @@ -95,7 +154,37 @@ Creates a new application for a candidate on a job. Optionally specify interview | Parameter | Type | Description | | --------- | ---- | ----------- | -| `applicationId` | string | Created application UUID | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_create_candidate` @@ -107,7 +196,7 @@ Creates a new candidate record in Ashby. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | | `name` | string | Yes | The candidate full name | -| `email` | string | Yes | Primary email address for the candidate | +| `email` | string | No | Primary email address for the candidate | | `phoneNumber` | string | No | Primary phone number for the candidate | | `linkedInUrl` | string | No | LinkedIn profile URL | | `githubUrl` | string | No | GitHub profile URL | @@ -117,17 +206,37 @@ Creates a new candidate record in Ashby. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Created candidate UUID | -| `name` | string | Full name | -| `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_create_note` @@ -147,7 +256,15 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo | Parameter | Type | Description | | --------- | ---- | ----------- | -| `noteId` | string | Created note UUID | +| `id` | string | Created note UUID | +| `createdAt` | string | ISO 8601 creation timestamp | +| `isPrivate` | boolean | Whether the note is private | +| `content` | string | Note content | +| `author` | object | Author of the note | +| ↳ `id` | string | Author user UUID | +| ↳ `firstName` | string | Author first name | +| ↳ `lastName` | string | Author last name | +| ↳ `email` | string | Author email | ### `ashby_get_application` @@ -164,28 +281,37 @@ Retrieves full details about a single application by its ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Application UUID | -| `status` | string | Application status \(Active, Hired, Archived, Lead\) | -| `candidate` | object | Associated candidate | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Candidate name | -| `job` | object | Associated job | -| ↳ `id` | string | Job UUID | -| ↳ `title` | string | Job title | -| `currentInterviewStage` | object | Current interview stage | -| ↳ `id` | string | Stage UUID | -| ↳ `title` | string | Stage title | -| ↳ `type` | string | Stage type | -| `source` | object | Application source | -| ↳ `id` | string | Source UUID | -| ↳ `title` | string | Source title | -| `archiveReason` | object | Reason for archival | -| ↳ `id` | string | Reason UUID | -| ↳ `text` | string | Reason text | -| ↳ `reasonType` | string | Reason type | -| `archivedAt` | string | ISO 8601 archive timestamp | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_get_candidate` @@ -202,27 +328,37 @@ Retrieves full details about a single candidate by their ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Candidate UUID | -| `name` | string | Full name | -| `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| `profileUrl` | string | URL to the candidate Ashby profile | -| `position` | string | Current position or title | -| `company` | string | Current company | -| `linkedInUrl` | string | LinkedIn profile URL | -| `githubUrl` | string | GitHub profile URL | -| `tags` | array | Tags applied to the candidate | -| ↳ `id` | string | Tag UUID | -| ↳ `title` | string | Tag title | -| `applicationIds` | array | IDs of associated applications | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_get_job` @@ -239,16 +375,37 @@ Retrieves full details about a single job by its ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Job UUID | -| `title` | string | Job title | -| `status` | string | Job status \(Open, Closed, Draft, Archived\) | -| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | -| `departmentId` | string | Department UUID | -| `locationId` | string | Location UUID | -| `descriptionPlain` | string | Job description in plain text | -| `isArchived` | boolean | Whether the job is archived | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_get_job_posting` @@ -260,6 +417,8 @@ Retrieves full details about a single job posting by its ID. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | | `jobPostingId` | string | Yes | The UUID of the job posting to fetch | +| `expandApplicationFormDefinition` | boolean | No | Include application form definition in the response | +| `expandSurveyFormDefinitions` | boolean | No | Include survey form definitions in the response | #### Output @@ -267,14 +426,56 @@ Retrieves full details about a single job posting by its ID. | --------- | ---- | ----------- | | `id` | string | Job posting UUID | | `title` | string | Job posting title | -| `jobId` | string | Associated job UUID | -| `locationName` | string | Location name | +| `descriptionPlain` | string | Full description in plain text | +| `descriptionHtml` | string | Full description in HTML | +| `descriptionSocial` | string | Shortened description for social sharing \(max 200 chars\) | +| `descriptionParts` | object | Description broken into opening, body, and closing sections | +| ↳ `descriptionOpening` | object | Opening \(from Job Boards theme settings\) | +| ↳ `html` | string | HTML content | +| ↳ `plain` | string | Plain text content | +| ↳ `descriptionBody` | object | Main description body | +| ↳ `html` | string | HTML content | +| ↳ `plain` | string | Plain text content | +| ↳ `descriptionClosing` | object | Closing \(from Job Boards theme settings\) | +| ↳ `html` | string | HTML content | +| ↳ `plain` | string | Plain text content | | `departmentName` | string | Department name | -| `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) | -| `descriptionPlain` | string | Job posting description in plain text | -| `isListed` | boolean | Whether the posting is publicly listed | +| `teamName` | string | Team name | +| `teamNameHierarchy` | array | Hierarchy of team names from root to team | +| `jobId` | string | Associated job UUID | +| `locationName` | string | Primary location name | +| `locationIds` | object | Primary and secondary location UUIDs | +| ↳ `primaryLocationId` | string | Primary location UUID | +| ↳ `secondaryLocationIds` | array | Secondary location UUIDs | +| `address` | object | Postal address of the posting location | +| ↳ `postalAddress` | object | Structured postal address | +| ↳ `addressCountry` | string | Country | +| ↳ `addressRegion` | string | State or region | +| ↳ `addressLocality` | string | City or locality | +| ↳ `postalCode` | string | Postal code | +| ↳ `streetAddress` | string | Street address | +| `isRemote` | boolean | Whether the posting is remote | +| `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) | +| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | +| `isListed` | boolean | Whether publicly listed on the job board | +| `suppressDescriptionOpening` | boolean | Whether the theme opening is hidden on this posting | +| `suppressDescriptionClosing` | boolean | Whether the theme closing is hidden on this posting | | `publishedDate` | string | ISO 8601 published date | +| `applicationDeadline` | string | ISO 8601 application deadline | | `externalLink` | string | External link to the job posting | +| `applyLink` | string | Direct apply link | +| `compensation` | object | Compensation details for the posting | +| ↳ `compensationTierSummary` | string | Human-readable tier summary | +| ↳ `summaryComponents` | array | Structured compensation components | +| ↳ `summary` | string | Component summary | +| ↳ `compensationTypeLabel` | string | Component type label \(Salary, Commission, Bonus, Equity, etc.\) | +| ↳ `interval` | string | Payment interval \(e.g. annual, hourly\) | +| ↳ `currencyCode` | string | ISO 4217 currency code | +| ↳ `minValue` | number | Minimum value | +| ↳ `maxValue` | number | Maximum value | +| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board | +| `applicationLimitCalloutHtml` | string | HTML callout shown when application limit is reached | +| `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_get_offer` @@ -291,20 +492,41 @@ Retrieves full details about a single offer by its ID. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Offer UUID | -| `offerStatus` | string | Offer status \(e.g. WaitingOnCandidateResponse, CandidateAccepted\) | -| `acceptanceStatus` | string | Acceptance status \(e.g. Accepted, Declined, Pending\) | -| `applicationId` | string | Associated application UUID | -| `startDate` | string | Offer start date | -| `salary` | object | Salary details | -| ↳ `currencyCode` | string | ISO 4217 currency code | -| ↳ `value` | number | Salary amount | -| `openingId` | string | Associated opening UUID | -| `createdAt` | string | ISO 8601 creation timestamp \(from latest version\) | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_list_applications` -Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date. +Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date. #### Input @@ -315,7 +537,6 @@ Lists all applications in an Ashby organization with pagination and optional fil | `perPage` | number | No | Number of results per page \(default 100\) | | `status` | string | No | Filter by application status: Active, Hired, Archived, or Lead | | `jobId` | string | No | Filter applications by a specific job UUID | -| `candidateId` | string | No | Filter applications by a specific candidate UUID | | `createdAfter` | string | No | Filter to applications created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) | #### Output @@ -323,23 +544,6 @@ Lists all applications in an Ashby organization with pagination and optional fil | Parameter | Type | Description | | --------- | ---- | ----------- | | `applications` | array | List of applications | -| ↳ `id` | string | Application UUID | -| ↳ `status` | string | Application status \(Active, Hired, Archived, Lead\) | -| ↳ `candidate` | object | Associated candidate | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Candidate name | -| ↳ `job` | object | Associated job | -| ↳ `id` | string | Job UUID | -| ↳ `title` | string | Job title | -| ↳ `currentInterviewStage` | object | Current interview stage | -| ↳ `id` | string | Stage UUID | -| ↳ `title` | string | Stage title | -| ↳ `type` | string | Stage type | -| ↳ `source` | object | Application source | -| ↳ `id` | string | Source UUID | -| ↳ `title` | string | Source title | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -352,6 +556,7 @@ Lists all archive reasons configured in Ashby. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | +| `includeArchived` | boolean | No | Whether to include archived archive reasons in the response \(default false\) | #### Output @@ -360,7 +565,7 @@ Lists all archive reasons configured in Ashby. | `archiveReasons` | array | List of archive reasons | | ↳ `id` | string | Archive reason UUID | | ↳ `text` | string | Archive reason text | -| ↳ `reasonType` | string | Reason type | +| ↳ `reasonType` | string | Reason type \(RejectedByCandidate, RejectedByOrg, Other\) | | ↳ `isArchived` | boolean | Whether the reason is archived | ### `ashby_list_candidate_tags` @@ -372,6 +577,10 @@ Lists all candidate tags configured in Ashby. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Ashby API Key | +| `includeArchived` | boolean | No | Whether to include archived candidate tags \(default false\) | +| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value | +| `syncToken` | string | No | Sync token from a previous response to fetch only changed results | +| `perPage` | number | No | Number of results per page \(default 100\) | #### Output @@ -381,6 +590,9 @@ Lists all candidate tags configured in Ashby. | ↳ `id` | string | Tag UUID | | ↳ `title` | string | Tag title | | ↳ `isArchived` | boolean | Whether the tag is archived | +| `moreDataAvailable` | boolean | Whether more pages of results exist | +| `nextCursor` | string | Opaque cursor for fetching the next page | +| `syncToken` | string | Sync token to use for incremental updates in future requests | ### `ashby_list_candidates` @@ -399,18 +611,6 @@ Lists all candidates in an Ashby organization with cursor-based pagination. | Parameter | Type | Description | | --------- | ---- | ----------- | | `candidates` | array | List of candidates | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Full name | -| ↳ `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| ↳ `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -431,9 +631,15 @@ Lists all custom field definitions configured in Ashby. | `customFields` | array | List of custom field definitions | | ↳ `id` | string | Custom field UUID | | ↳ `title` | string | Custom field title | -| ↳ `fieldType` | string | Field type \(e.g. String, Number, Boolean\) | -| ↳ `objectType` | string | Object type the field applies to \(e.g. Candidate, Application, Job\) | +| ↳ `isPrivate` | boolean | Whether the custom field is private | +| ↳ `fieldType` | string | Field data type \(MultiValueSelect, NumberRange, String, Date, ValueSelect, Number, Currency, Boolean, LongText, CompensationRange\) | +| ↳ `objectType` | string | Object type the field applies to \(Application, Candidate, Employee, Job, Offer, Opening, Talent_Project\) | | ↳ `isArchived` | boolean | Whether the custom field is archived | +| ↳ `isRequired` | boolean | Whether a value is required | +| ↳ `selectableValues` | array | Selectable values for MultiValueSelect fields \(empty for other field types\) | +| ↳ `label` | string | Display label | +| ↳ `value` | string | Stored value | +| ↳ `isArchived` | boolean | Whether archived | ### `ashby_list_departments` @@ -452,8 +658,11 @@ Lists all departments in Ashby. | `departments` | array | List of departments | | ↳ `id` | string | Department UUID | | ↳ `name` | string | Department name | +| ↳ `externalName` | string | Candidate-facing name used on job boards | | ↳ `isArchived` | boolean | Whether the department is archived | | ↳ `parentId` | string | Parent department UUID | +| ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_list_interviews` @@ -475,10 +684,24 @@ Lists interview schedules in Ashby, optionally filtered by application or interv | --------- | ---- | ----------- | | `interviewSchedules` | array | List of interview schedules | | ↳ `id` | string | Interview schedule UUID | +| ↳ `status` | string | Schedule status \(NeedsScheduling, WaitingOnCandidateBooking, Scheduled, Complete, Cancelled, OnHold, etc.\) | | ↳ `applicationId` | string | Associated application UUID | | ↳ `interviewStageId` | string | Interview stage UUID | -| ↳ `status` | string | Schedule status | | ↳ `createdAt` | string | ISO 8601 creation timestamp | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | +| ↳ `interviewEvents` | array | Scheduled interview events on this schedule | +| ↳ `id` | string | Event UUID | +| ↳ `interviewId` | string | Interview template UUID | +| ↳ `interviewScheduleId` | string | Parent schedule UUID | +| ↳ `interviewerUserIds` | array | User UUIDs of interviewers assigned to the event | +| ↳ `createdAt` | string | Event creation timestamp | +| ↳ `updatedAt` | string | Event last updated timestamp | +| ↳ `startTime` | string | Event start time | +| ↳ `endTime` | string | Event end time | +| ↳ `feedbackLink` | string | URL to submit feedback for the event | +| ↳ `location` | string | Physical location | +| ↳ `meetingLink` | string | Virtual meeting URL | +| ↳ `hasSubmittedFeedback` | boolean | Whether any feedback has been submitted | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -500,11 +723,22 @@ Lists all job postings in Ashby. | ↳ `id` | string | Job posting UUID | | ↳ `title` | string | Job posting title | | ↳ `jobId` | string | Associated job UUID | -| ↳ `locationName` | string | Location name | | ↳ `departmentName` | string | Department name | -| ↳ `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) | +| ↳ `teamName` | string | Team name | +| ↳ `locationName` | string | Primary location display name | +| ↳ `locationIds` | object | Primary and secondary location UUIDs | +| ↳ `primaryLocationId` | string | Primary location UUID | +| ↳ `secondaryLocationIds` | array | Secondary location UUIDs | +| ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) | +| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | | ↳ `isListed` | boolean | Whether the posting is publicly listed | | ↳ `publishedDate` | string | ISO 8601 published date | +| ↳ `applicationDeadline` | string | ISO 8601 application deadline | +| ↳ `externalLink` | string | External link to the job posting | +| ↳ `applyLink` | string | Direct apply link for the job posting | +| ↳ `compensationTierSummary` | string | Compensation tier summary for job boards | +| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board | +| ↳ `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_list_jobs` @@ -524,14 +758,6 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar | Parameter | Type | Description | | --------- | ---- | ----------- | | `jobs` | array | List of jobs | -| ↳ `id` | string | Job UUID | -| ↳ `title` | string | Job title | -| ↳ `status` | string | Job status \(Open, Closed, Archived, Draft\) | -| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) | -| ↳ `departmentId` | string | Department UUID | -| ↳ `locationId` | string | Location UUID | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -552,12 +778,18 @@ Lists all locations configured in Ashby. | `locations` | array | List of locations | | ↳ `id` | string | Location UUID | | ↳ `name` | string | Location name | +| ↳ `externalName` | string | Candidate-facing name used on job boards | | ↳ `isArchived` | boolean | Whether the location is archived | -| ↳ `isRemote` | boolean | Whether this is a remote location | -| ↳ `address` | object | Location address | -| ↳ `city` | string | City | -| ↳ `region` | string | State or region | -| ↳ `country` | string | Country | +| ↳ `isRemote` | boolean | Whether the location is remote \(use workplaceType instead\) | +| ↳ `workplaceType` | string | Workplace type \(OnSite, Hybrid, Remote\) | +| ↳ `parentLocationId` | string | Parent location UUID | +| ↳ `type` | string | Location component type \(Location, LocationHierarchy\) | +| ↳ `address` | object | Location postal address | +| ↳ `addressCountry` | string | Country | +| ↳ `addressRegion` | string | State or region | +| ↳ `addressLocality` | string | City or locality | +| ↳ `postalCode` | string | Postal code | +| ↳ `streetAddress` | string | Street address | ### `ashby_list_notes` @@ -579,6 +811,7 @@ Lists all notes on a candidate with pagination support. | `notes` | array | List of notes on the candidate | | ↳ `id` | string | Note UUID | | ↳ `content` | string | Note content | +| ↳ `isPrivate` | boolean | Whether the note is private | | ↳ `author` | object | Note author | | ↳ `id` | string | Author user UUID | | ↳ `firstName` | string | First name | @@ -605,16 +838,6 @@ Lists all offers with their latest version in an Ashby organization. | Parameter | Type | Description | | --------- | ---- | ----------- | | `offers` | array | List of offers | -| ↳ `id` | string | Offer UUID | -| ↳ `offerStatus` | string | Offer status | -| ↳ `acceptanceStatus` | string | Acceptance status | -| ↳ `applicationId` | string | Associated application UUID | -| ↳ `startDate` | string | Offer start date | -| ↳ `salary` | object | Salary details | -| ↳ `currencyCode` | string | ISO 4217 currency code | -| ↳ `value` | number | Salary amount | -| ↳ `openingId` | string | Associated opening UUID | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -634,12 +857,6 @@ Lists all openings in Ashby with pagination. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `openings` | array | List of openings | -| ↳ `id` | string | Opening UUID | -| ↳ `openingState` | string | Opening state \(Approved, Closed, Draft, Filled, Open\) | -| ↳ `isArchived` | boolean | Whether the opening is archived | -| ↳ `openedAt` | string | ISO 8601 opened timestamp | -| ↳ `closedAt` | string | ISO 8601 closed timestamp | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | @@ -661,6 +878,10 @@ Lists all candidate sources configured in Ashby. | ↳ `id` | string | Source UUID | | ↳ `title` | string | Source title | | ↳ `isArchived` | boolean | Whether the source is archived | +| ↳ `sourceType` | object | Source type grouping | +| ↳ `id` | string | Source type UUID | +| ↳ `title` | string | Source type title | +| ↳ `isArchived` | boolean | Whether archived | ### `ashby_list_users` @@ -679,18 +900,12 @@ Lists all users in Ashby with pagination. | Parameter | Type | Description | | --------- | ---- | ----------- | | `users` | array | List of users | -| ↳ `id` | string | User UUID | -| ↳ `firstName` | string | First name | -| ↳ `lastName` | string | Last name | -| ↳ `email` | string | Email address | -| ↳ `isEnabled` | boolean | Whether the user account is enabled | -| ↳ `globalRole` | string | User role \(Organization Admin, Elevated Access, Limited Access, External Recruiter\) | | `moreDataAvailable` | boolean | Whether more pages of results exist | | `nextCursor` | string | Opaque cursor for fetching the next page | ### `ashby_remove_candidate_tag` -Removes a tag from a candidate in Ashby. +Removes a tag from a candidate in Ashby and returns the updated candidate. #### Input @@ -704,7 +919,37 @@ Removes a tag from a candidate in Ashby. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Whether the tag was successfully removed | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | +| `createdAt` | string | ISO 8601 creation timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | ### `ashby_search_candidates` @@ -723,18 +968,6 @@ Searches for candidates by name and/or email with AND logic. Results are limited | Parameter | Type | Description | | --------- | ---- | ----------- | | `candidates` | array | Matching candidates \(max 100 results\) | -| ↳ `id` | string | Candidate UUID | -| ↳ `name` | string | Full name | -| ↳ `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| ↳ `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| ↳ `createdAt` | string | ISO 8601 creation timestamp | -| ↳ `updatedAt` | string | ISO 8601 last update timestamp | ### `ashby_update_candidate` @@ -758,26 +991,36 @@ Updates an existing candidate record in Ashby. Only provided fields are changed. | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Candidate UUID | -| `name` | string | Full name | -| `primaryEmailAddress` | object | Primary email contact info | -| ↳ `value` | string | Email address | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary email | -| `primaryPhoneNumber` | object | Primary phone contact info | -| ↳ `value` | string | Phone number | -| ↳ `type` | string | Contact type \(Personal, Work, Other\) | -| ↳ `isPrimary` | boolean | Whether this is the primary phone | -| `profileUrl` | string | URL to the candidate Ashby profile | -| `position` | string | Current position or title | -| `company` | string | Current company | -| `linkedInUrl` | string | LinkedIn profile URL | -| `githubUrl` | string | GitHub profile URL | -| `tags` | array | Tags applied to the candidate | -| ↳ `id` | string | Tag UUID | -| ↳ `title` | string | Tag title | -| `applicationIds` | array | IDs of associated applications | +| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) | +| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion, compensation with compensationTiers\[\]\) | +| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) | +| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) | +| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) | +| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) | +| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) | +| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) | +| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) | +| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) | +| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) | +| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) | +| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId\) | +| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) | +| `tags` | json | List of candidate tags \(id, title, isArchived\) | +| `id` | string | Resource UUID | +| `name` | string | Resource name | +| `title` | string | Job title or job posting title | +| `status` | string | Status | +| `candidate` | json | Candidate details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) | +| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, createdAt, updatedAt\) | +| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) | +| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) | +| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) | +| `content` | string | Note content | +| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) | +| `isPrivate` | boolean | Whether the note is private | | `createdAt` | string | ISO 8601 creation timestamp | -| `updatedAt` | string | ISO 8601 last update timestamp | +| `moreDataAvailable` | boolean | Whether more pages exist | +| `nextCursor` | string | Pagination cursor for next page | +| `syncToken` | string | Sync token for incremental updates | diff --git a/apps/docs/content/docs/en/triggers/ashby.mdx b/apps/docs/content/docs/en/triggers/ashby.mdx index aad15714728..b4f557d05f3 100644 --- a/apps/docs/content/docs/en/triggers/ashby.mdx +++ b/apps/docs/content/docs/en/triggers/ashby.mdx @@ -97,6 +97,14 @@ Trigger workflow when a candidate is hired | ↳ `job` | object | job output from the tool | | ↳ `id` | string | Job UUID | | ↳ `title` | string | Job title | +| `offer` | object | offer output from the tool | +| ↳ `id` | string | Accepted offer UUID | +| ↳ `applicationId` | string | Associated application UUID | +| ↳ `acceptanceStatus` | string | Offer acceptance status | +| ↳ `offerStatus` | string | Offer process status | +| ↳ `decidedAt` | string | Offer decision timestamp \(ISO 8601\) | +| ↳ `latestVersion` | object | latestVersion output from the tool | +| ↳ `id` | string | Latest offer version UUID | --- diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 42cc823ea18..360f103bbcd 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -1031,7 +1031,7 @@ }, { "name": "List Applications", - "description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date." + "description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date." }, { "name": "Get Application", @@ -1051,11 +1051,11 @@ }, { "name": "Add Candidate Tag", - "description": "Adds a tag to a candidate in Ashby." + "description": "Adds a tag to a candidate in Ashby and returns the updated candidate." }, { "name": "Remove Candidate Tag", - "description": "Removes a tag from a candidate in Ashby." + "description": "Removes a tag from a candidate in Ashby and returns the updated candidate." }, { "name": "Get Offer", diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index 803f91af2ca..aa7c85b250f 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -38,6 +38,7 @@ vi.mock('@/lib/copilot/request/session', () => ({ }), encodeSSEEnvelope: (event: Record) => new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream', }, @@ -132,6 +133,7 @@ describe('copilot chat stream replay route', () => { ) const chunks = await readAllChunks(response) + expect(chunks[0]).toBe(': accepted\n\n') expect(chunks.join('')).toContain( JSON.stringify({ status: MothershipStreamV1CompletionStatus.cancelled, diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 45a4c3c9875..3d7ab03b438 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -19,6 +19,7 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel' import { checkForReplayGap, createEvent, + encodeSSEComment, encodeSSEEnvelope, readEvents, readFilePreviewSessions, @@ -31,6 +32,7 @@ export const maxDuration = 3600 const logger = createLogger('CopilotChatStreamAPI') const POLL_INTERVAL_MS = 250 +const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000 const MAX_STREAM_MS = 60 * 60 * 1000 function extractCanonicalRequestId(value: unknown): string { @@ -266,6 +268,7 @@ async function handleResumeRequestBody({ let controllerClosed = false let sawTerminalEvent = false let currentRequestId = extractRunRequestId(run) + let lastWriteTime = Date.now() // Stamp the logical request id + chat id on the resume root as soon // as we resolve them from the run row, so TraceQL joins work on // resume legs the same way they do on the original POST. @@ -291,6 +294,19 @@ async function handleResumeRequestBody({ if (controllerClosed) return false try { controller.enqueue(encodeSSEEnvelope(payload)) + lastWriteTime = Date.now() + return true + } catch { + controllerClosed = true + return false + } + } + + const enqueueComment = (comment: string) => { + if (controllerClosed) return false + try { + controller.enqueue(encodeSSEComment(comment)) + lastWriteTime = Date.now() return true } catch { controllerClosed = true @@ -306,7 +322,6 @@ async function handleResumeRequestBody({ const flushEvents = async () => { const events = await readEvents(streamId, cursor) if (events.length > 0) { - totalEventsFlushed += events.length logger.debug('[Resume] Flushing events', { streamId, afterCursor: cursor, @@ -314,14 +329,15 @@ async function handleResumeRequestBody({ }) } for (const envelope of events) { + if (!enqueueEvent(envelope)) { + break + } + totalEventsFlushed += 1 cursor = envelope.stream.cursor ?? String(envelope.seq) currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId if (envelope.type === MothershipStreamV1EventType.complete) { sawTerminalEvent = true } - if (!enqueueEvent(envelope)) { - break - } } } @@ -341,21 +357,30 @@ async function handleResumeRequestBody({ reason: options?.reason, requestId: currentRequestId, })) { + if (!enqueueEvent(envelope)) { + break + } cursor = envelope.stream.cursor ?? String(envelope.seq) if (envelope.type === MothershipStreamV1EventType.complete) { sawTerminalEvent = true } - if (!enqueueEvent(envelope)) { - break - } } } try { + enqueueComment('accepted') + const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId) if (gap) { for (const envelope of gap.envelopes) { - enqueueEvent(envelope) + if (!enqueueEvent(envelope)) { + break + } + cursor = envelope.stream.cursor ?? String(envelope.seq) + currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId + if (envelope.type === MothershipStreamV1EventType.complete) { + sawTerminalEvent = true + } } return } @@ -408,6 +433,10 @@ async function handleResumeRequestBody({ break } + if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) { + enqueueComment('keepalive') + } + await sleep(POLL_INTERVAL_MS) } if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 151437fcdd8..99e467a20c6 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -66,6 +66,12 @@ const QueryRowsSchema = z.object({ .min(0, 'Offset must be 0 or greater') .optional() .default(0), + includeTotal: z + .preprocess( + (val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'), + z.boolean().optional() + ) + .default(true), }) const nonEmptyFilter = z @@ -328,6 +334,7 @@ export const GET = withRouteHandler( const sortParam = searchParams.get('sort') const limit = searchParams.get('limit') const offset = searchParams.get('offset') + const includeTotalParam = searchParams.get('includeTotal') let filter: Record | undefined let sort: Sort | undefined @@ -349,6 +356,7 @@ export const GET = withRouteHandler( sort, limit, offset, + includeTotal: includeTotalParam, }) const accessResult = await checkAccess(tableId, authResult.userId, 'read') @@ -398,17 +406,19 @@ export const GET = withRouteHandler( query = query.orderBy(userTableRows.position) as typeof query } - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) - - const [{ count: totalCount }] = await countQuery + let totalCount: number | null = null + if (validated.includeTotal) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) + totalCount = Number(count) + } const rows = await query.limit(validated.limit).offset(validated.offset) logger.info( - `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})` + `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})` ) return NextResponse.json({ @@ -424,7 +434,7 @@ export const GET = withRouteHandler( r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), })), rowCount: rows.length, - totalCount: Number(totalCount), + totalCount, limit: validated.limit, offset: validated.offset, }, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index d2a8f837cec..406b2f6c17b 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -71,6 +71,12 @@ const QueryRowsSchema = z.object({ .optional() ) .default(0), + includeTotal: z + .preprocess( + (val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'), + z.boolean().optional() + ) + .default(true), }) const nonEmptyFilter = z @@ -219,6 +225,7 @@ export const GET = withRouteHandler( sort, limit: searchParams.get('limit'), offset: searchParams.get('offset'), + includeTotal: searchParams.get('includeTotal'), }) const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) @@ -268,16 +275,37 @@ export const GET = withRouteHandler( query = query.orderBy(userTableRows.position) as typeof query } - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) + const rowsPromise = query.limit(validated.limit).offset(validated.offset) + + let totalCount: number | null = null + if (validated.includeTotal) { + const countQuery = db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) + const [countResult, rows] = await Promise.all([countQuery, rowsPromise]) + totalCount = Number(countResult[0].count) + return NextResponse.json({ + success: true, + data: { + rows: rows.map((r) => ({ + id: r.id, + data: r.data, + position: r.position, + createdAt: + r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), + updatedAt: + r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), + })), + rowCount: rows.length, + totalCount, + limit: validated.limit, + offset: validated.offset, + }, + }) + } - const [countResult, rows] = await Promise.all([ - countQuery, - query.limit(validated.limit).offset(validated.offset), - ]) - const totalCount = countResult[0].count + const rows = await rowsPromise return NextResponse.json({ success: true, @@ -292,7 +320,7 @@ export const GET = withRouteHandler( r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), })), rowCount: rows.length, - totalCount: Number(totalCount), + totalCount, limit: validated.limit, offset: validated.offset, }, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index b477c4744e4..ccf194d7328 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useLayoutEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { cn } from '@/lib/core/utils/cn' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' @@ -22,6 +22,7 @@ import type { QueuedMessage, } from '@/app/workspace/[workspaceId]/home/types' import { useAutoScroll } from '@/hooks/use-auto-scroll' +import { useProgressiveList } from '@/hooks/use-progressive-list' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './mothership-chat-skeleton' @@ -104,6 +105,21 @@ export function MothershipChat({ scrollOnMount: true, }) const hasMessages = messages.length > 0 + const stagingKey = chatId ?? 'pending-chat' + const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey) + const stagedMessageCount = stagedMessages.length + const stagedOffset = messages.length - stagedMessages.length + const precedingUserContentByIndex = useMemo(() => { + const contentByIndex: Array = [] + let lastUserContent: string | undefined + for (const [index, message] of messages.entries()) { + contentByIndex[index] = lastUserContent + if (message.role === 'user') { + lastUserContent = message.content + } + } + return contentByIndex + }, [messages]) const initialScrollDoneRef = useRef(false) const userInputRef = useRef(null) const handleSendQueuedHead = useCallback(() => { @@ -134,6 +150,11 @@ export function MothershipChat({ scrollToBottom() }, [hasMessages, initialScrollBlocked, scrollToBottom]) + useLayoutEffect(() => { + if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return + scrollToBottom() + }, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom]) + return (
@@ -141,7 +162,8 @@ export function MothershipChat({ ) : (
- {messages.map((msg, index) => { + {stagedMessages.map((msg, localIndex) => { + const index = stagedOffset + localIndex if (msg.role === 'user') { const hasAttachments = Boolean(msg.attachments?.length) return ( @@ -177,10 +199,7 @@ export function MothershipChat({ } const isLastMessage = index === messages.length - 1 - const precedingUserMsg = [...messages] - .slice(0, index) - .reverse() - .find((m) => m.role === 'user') + const precedingUserContent = precedingUserContentByIndex[index] return (
@@ -196,7 +215,7 @@ export function MothershipChat({
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts index 992d1ca653e..03be2ab293f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-data.ts @@ -34,6 +34,7 @@ export function useTableData({ offset: 0, filter: queryOptions.filter, sort: queryOptions.sort, + includeTotal: false, enabled: Boolean(workspaceId && tableId), }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx index 492f2f09844..f7ef31ad5cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx @@ -6,6 +6,7 @@ import type { ComboboxOption } from '@/components/emcn' import { useTableColumns } from '@/lib/table/hooks' import type { FilterRule } from '@/lib/table/query-builder/constants' import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder' +import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { FilterRuleRow } from './components/filter-rule-row' @@ -40,7 +41,7 @@ export function FilterBuilder({ tableIdSubBlockId = 'tableId', }: FilterBuilderProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) - const [tableIdValue] = useSubBlockValue(blockId, tableIdSubBlockId) + const tableIdValue = useCanonicalSubBlockValue(blockId, tableIdSubBlockId) const dynamicColumns = useTableColumns({ tableId: tableIdValue }) const columns = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx index 12213e0b63a..b202c4a2a93 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id' import type { ComboboxOption } from '@/components/emcn' import { useTableColumns } from '@/lib/table/hooks' import { SORT_DIRECTIONS, type SortRule } from '@/lib/table/query-builder/constants' +import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { SortRuleRow } from './components/sort-rule-row' @@ -36,7 +37,7 @@ export function SortBuilder({ tableIdSubBlockId = 'tableId', }: SortBuilderProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) - const [tableIdValue] = useSubBlockValue(blockId, tableIdSubBlockId) + const tableIdValue = useCanonicalSubBlockValue(blockId, tableIdSubBlockId) const dynamicColumns = useTableColumns({ tableId: tableIdValue, includeBuiltIn: true }) const columns = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts new file mode 100644 index 00000000000..3cb5fc25bfc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts @@ -0,0 +1,49 @@ +import { useCallback, useMemo } from 'react' +import { isEqual } from 'es-toolkit' +import { useStoreWithEqualityFn } from 'zustand/traditional' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +/** + * Read a sub-block value by either its raw subBlockId or its canonicalParamId. + * + * `useSubBlockValue` only looks up the raw subBlockId. For fields that use + * `canonicalParamId` to unify basic/advanced inputs (e.g. `tableSelector` vs + * `manualTableId` both mapping to `tableId`), this hook resolves to whichever + * member of the canonical group currently holds the value. + */ +export function useCanonicalSubBlockValue( + blockId: string, + canonicalOrSubBlockId: string +): T | null { + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes + + return useStoreWithEqualityFn( + useSubBlockStore, + useCallback( + (state) => { + if (!activeWorkflowId) return null + const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] || {} + const resolved = resolveDependencyValue( + canonicalOrSubBlockId, + blockValues, + canonicalIndex, + canonicalModeOverrides + ) + return (resolved ?? null) as T | null + }, + [activeWorkflowId, blockId, canonicalOrSubBlockId, canonicalIndex, canonicalModeOverrides] + ), + (a, b) => isEqual(a, b) + ) +} diff --git a/apps/sim/blocks/blocks/ashby.ts b/apps/sim/blocks/blocks/ashby.ts index 0659dcbeda3..1113c6f6983 100644 --- a/apps/sim/blocks/blocks/ashby.ts +++ b/apps/sim/blocks/blocks/ashby.ts @@ -113,7 +113,6 @@ export const AshbyBlock: BlockConfig = { id: 'email', title: 'Email', type: 'short-input', - required: { field: 'operation', value: 'create_candidate' }, placeholder: 'Email address', condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] }, }, @@ -308,14 +307,6 @@ Output only the ISO 8601 timestamp string, nothing else.`, condition: { field: 'operation', value: 'list_applications' }, mode: 'advanced', }, - { - id: 'filterCandidateId', - title: 'Candidate ID Filter', - type: 'short-input', - placeholder: 'Filter by candidate UUID', - condition: { field: 'operation', value: 'list_applications' }, - mode: 'advanced', - }, { id: 'createdAfter', title: 'Created After', @@ -366,6 +357,7 @@ Output only the ISO 8601 timestamp string, nothing else.`, 'list_openings', 'list_users', 'list_interviews', + 'list_candidate_tags', ], }, mode: 'advanced', @@ -386,10 +378,43 @@ Output only the ISO 8601 timestamp string, nothing else.`, 'list_openings', 'list_users', 'list_interviews', + 'list_candidate_tags', ], }, mode: 'advanced', }, + { + id: 'syncToken', + title: 'Sync Token', + type: 'short-input', + placeholder: 'Sync token for incremental updates', + condition: { field: 'operation', value: 'list_candidate_tags' }, + mode: 'advanced', + }, + { + id: 'includeArchived', + title: 'Include Archived', + type: 'switch', + condition: { + field: 'operation', + value: ['list_candidate_tags', 'list_archive_reasons'], + }, + mode: 'advanced', + }, + { + id: 'expandApplicationFormDefinition', + title: 'Include Application Form Definition', + type: 'switch', + condition: { field: 'operation', value: 'get_job_posting' }, + mode: 'advanced', + }, + { + id: 'expandSurveyFormDefinitions', + title: 'Include Survey Form Definitions', + type: 'switch', + condition: { field: 'operation', value: 'get_job_posting' }, + mode: 'advanced', + }, { id: 'tagId', title: 'Tag ID', @@ -476,11 +501,25 @@ Output only the ISO 8601 timestamp string, nothing else.`, if (params.searchEmail) result.email = params.searchEmail if (params.filterStatus) result.status = params.filterStatus if (params.filterJobId) result.jobId = params.filterJobId - if (params.filterCandidateId) result.candidateId = params.filterCandidateId if (params.jobStatus) result.status = params.jobStatus if (params.sendNotifications === 'true' || params.sendNotifications === true) { result.sendNotifications = true } + if (params.includeArchived === 'true' || params.includeArchived === true) { + result.includeArchived = true + } + if ( + params.expandApplicationFormDefinition === 'true' || + params.expandApplicationFormDefinition === true + ) { + result.expandApplicationFormDefinition = true + } + if ( + params.expandSurveyFormDefinitions === 'true' || + params.expandSurveyFormDefinitions === true + ) { + result.expandSurveyFormDefinitions = true + } if (params.appCandidateId) result.candidateId = params.appCandidateId if (params.appCreatedAt) result.createdAt = params.appCreatedAt if (params.updateName) result.name = params.updateName @@ -515,11 +554,20 @@ Output only the ISO 8601 timestamp string, nothing else.`, sendNotifications: { type: 'boolean', description: 'Send notifications' }, filterStatus: { type: 'string', description: 'Application status filter' }, filterJobId: { type: 'string', description: 'Job UUID filter' }, - filterCandidateId: { type: 'string', description: 'Candidate UUID filter' }, createdAfter: { type: 'string', description: 'Filter by creation date' }, jobStatus: { type: 'string', description: 'Job status filter' }, cursor: { type: 'string', description: 'Pagination cursor' }, perPage: { type: 'number', description: 'Results per page' }, + syncToken: { type: 'string', description: 'Sync token for incremental updates' }, + includeArchived: { type: 'boolean', description: 'Include archived records' }, + expandApplicationFormDefinition: { + type: 'boolean', + description: 'Include application form definition in job posting', + }, + expandSurveyFormDefinitions: { + type: 'boolean', + description: 'Include survey form definitions in job posting', + }, tagId: { type: 'string', description: 'Tag UUID' }, offerId: { type: 'string', description: 'Offer UUID' }, jobPostingId: { type: 'string', description: 'Job posting UUID' }, @@ -530,93 +578,113 @@ Output only the ISO 8601 timestamp string, nothing else.`, candidates: { type: 'json', description: - 'List of candidates (id, name, primaryEmailAddress, primaryPhoneNumber, createdAt, updatedAt)', + 'List of candidates with rich fields (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents[], tags[], applicationIds[], customFields[], resumeFileHandle, fileHandles[], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt)', }, jobs: { type: 'json', description: - 'List of jobs (id, title, status, employmentType, departmentId, locationId, createdAt, updatedAt)', + 'List of jobs (id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds[], customFields[], jobPostingIds[], customRequisitionId, brandId, hiringTeam[], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings[] with latestVersion, compensation with compensationTiers[])', }, applications: { type: 'json', description: - 'List of applications (id, status, candidate, job, currentInterviewStage, source, createdAt, updatedAt)', + 'List of applications (id, status, customFields[], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields[], archivedAt, job summary, creditedToUser, hiringTeam[], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt)', }, notes: { type: 'json', - description: 'List of notes (id, content, author, createdAt)', + description: 'List of notes (id, content, author, isPrivate, createdAt)', }, offers: { type: 'json', description: - 'List of offers (id, offerStatus, acceptanceStatus, applicationId, startDate, salary, openingId)', + 'List of offers (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields[]/fileHandles[]/author/approvalStatus)', }, archiveReasons: { type: 'json', - description: 'List of archive reasons (id, text, reasonType, isArchived)', + description: + 'List of archive reasons (id, text, reasonType [RejectedByCandidate/RejectedByOrg/Other], isArchived)', }, sources: { type: 'json', - description: 'List of sources (id, title, isArchived)', + description: 'List of sources (id, title, isArchived, sourceType {id, title, isArchived})', }, customFields: { type: 'json', - description: 'List of custom fields (id, title, fieldType, objectType, isArchived)', + description: + 'List of custom field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived})', }, departments: { type: 'json', - description: 'List of departments (id, name, isArchived, parentId)', + description: + 'List of departments (id, name, externalName, isArchived, parentId, createdAt, updatedAt)', }, locations: { type: 'json', - description: 'List of locations (id, name, isArchived, isRemote, address)', + description: + 'List of locations (id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress)', }, jobPostings: { type: 'json', description: - 'List of job postings (id, title, jobId, locationName, departmentName, employmentType, isListed, publishedDate)', + 'List of job postings (id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt)', }, openings: { type: 'json', - description: 'List of openings (id, openingState, isArchived, openedAt, closedAt)', + description: + 'List of openings (id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds[]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds[]/hiringTeam[]/customFields[])', }, users: { type: 'json', - description: 'List of users (id, firstName, lastName, email, isEnabled, globalRole)', + description: + 'List of users (id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId)', }, interviewSchedules: { type: 'json', description: - 'List of interview schedules (id, applicationId, interviewStageId, status, createdAt)', + 'List of interview schedules (id, applicationId, interviewStageId, interviewEvents[] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt)', }, tags: { type: 'json', description: 'List of candidate tags (id, title, isArchived)', }, - stageId: { type: 'string', description: 'Interview stage UUID after stage change' }, - success: { type: 'boolean', description: 'Whether the operation succeeded' }, - offerStatus: { - type: 'string', - description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)', + id: { type: 'string', description: 'Resource UUID' }, + name: { type: 'string', description: 'Resource name' }, + title: { type: 'string', description: 'Job title or job posting title' }, + status: { type: 'string', description: 'Status' }, + candidate: { + type: 'json', + description: + 'Candidate details (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], customFields[], source, creditedToUser, createdAt, updatedAt)', + }, + job: { + type: 'json', + description: + 'Job details (id, title, status, employmentType, locationId, departmentId, hiringTeam[], author, location, openings[], compensation, createdAt, updatedAt)', }, - acceptanceStatus: { - type: 'string', - description: 'Acceptance status (e.g. Accepted, Declined, Pending)', + application: { + type: 'json', + description: + 'Application details (id, status, customFields[], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam[], createdAt, updatedAt)', }, - applicationId: { type: 'string', description: 'Associated application UUID' }, - openingId: { type: 'string', description: 'Opening UUID associated with the offer' }, - salary: { + offer: { type: 'json', - description: 'Salary details from latest version (currencyCode, value)', + description: + 'Offer details (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion)', + }, + jobPosting: { + type: 'json', + description: + 'Job posting details (id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy[], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt)', }, - startDate: { type: 'string', description: 'Offer start date from latest version' }, - id: { type: 'string', description: 'Resource UUID' }, - name: { type: 'string', description: 'Resource name' }, - title: { type: 'string', description: 'Job title' }, - status: { type: 'string', description: 'Status' }, - noteId: { type: 'string', description: 'Created note UUID' }, content: { type: 'string', description: 'Note content' }, + author: { + type: 'json', + description: 'Note author (id, firstName, lastName, email, globalRole, isEnabled)', + }, + isPrivate: { type: 'boolean', description: 'Whether the note is private' }, + createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, moreDataAvailable: { type: 'boolean', description: 'Whether more pages exist' }, nextCursor: { type: 'string', description: 'Pagination cursor for next page' }, + syncToken: { type: 'string', description: 'Sync token for incremental updates' }, }, } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7b0d233d884..c0b6abb4bef 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -38,11 +38,14 @@ interface TableRowsParams { offset: number filter?: Filter | null sort?: Sort | null + /** When `false`, skip the server-side `COUNT(*)` and receive `totalCount: null`. */ + includeTotal?: boolean } interface TableRowsResponse { rows: TableRow[] - totalCount: number + /** `null` when the request opted out of the count via `includeTotal: false`. */ + totalCount: number | null } interface RowMutationContext { @@ -64,12 +67,14 @@ function createRowsParamsKey({ offset, filter, sort, + includeTotal, }: Omit): string { return JSON.stringify({ limit, offset, filter: filter ?? null, sort: sort ?? null, + includeTotal: includeTotal ?? true, }) } @@ -98,6 +103,7 @@ async function fetchTableRows({ offset, filter, sort, + includeTotal, signal, }: TableRowsParams & { signal?: AbortSignal }): Promise { const searchParams = new URLSearchParams({ @@ -114,6 +120,10 @@ async function fetchTableRows({ searchParams.set('sort', JSON.stringify(sort)) } + if (includeTotal === false) { + searchParams.set('includeTotal', 'false') + } + const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`, { signal }) if (!res.ok) { const error = await res.json().catch(() => ({})) @@ -121,15 +131,15 @@ async function fetchTableRows({ } const json: { - data?: { rows: TableRow[]; totalCount: number } + data?: { rows: TableRow[]; totalCount: number | null } rows?: TableRow[] - totalCount?: number + totalCount?: number | null } = await res.json() const data = json.data || json return { rows: (data.rows || []) as TableRow[], - totalCount: data.totalCount || 0, + totalCount: data.totalCount ?? null, } } @@ -209,9 +219,10 @@ export function useTableRows({ offset, filter, sort, + includeTotal, enabled = true, }: TableRowsParams & { enabled?: boolean }) { - const paramsKey = createRowsParamsKey({ limit, offset, filter, sort }) + const paramsKey = createRowsParamsKey({ limit, offset, filter, sort, includeTotal }) return useQuery({ queryKey: tableKeys.rows(tableId, paramsKey), @@ -223,6 +234,7 @@ export function useTableRows({ offset, filter, sort, + includeTotal, signal, }), enabled: Boolean(workspaceId && tableId) && enabled, @@ -393,7 +405,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) r.position >= row.position ? { ...r, position: r.position + 1 } : r ) const rows: TableRow[] = [...shifted, row].sort((a, b) => a.position - b.position) - return { ...old, rows, totalCount: old.totalCount + 1 } + return { + ...old, + rows, + totalCount: old.totalCount === null ? null : old.totalCount + 1, + } } ) }, diff --git a/apps/sim/hooks/use-progressive-list.ts b/apps/sim/hooks/use-progressive-list.ts index 74d7dc87a90..bf60f4d67b0 100644 --- a/apps/sim/hooks/use-progressive-list.ts +++ b/apps/sim/hooks/use-progressive-list.ts @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' interface ProgressiveListOptions { /** Number of items to render in the initial batch (most recent items) */ @@ -14,15 +14,31 @@ const DEFAULTS = { batchSize: 5, } satisfies Required +interface ProgressiveListState { + key: string + count: number + caughtUp: boolean +} + +function createInitialState( + key: string, + itemCount: number, + initialBatch: number +): ProgressiveListState { + const count = Math.min(itemCount, initialBatch) + return { + key, + count, + caughtUp: itemCount > 0 && count >= itemCount, + } +} + /** * Progressively renders a list of items so that first paint is fast. * * On mount (or when `key` changes), only the most recent `initialBatch` * items are rendered. The rest are added in `batchSize` increments via - * `requestAnimationFrame` so the browser never blocks on a large DOM mount. - * - * Once staging completes for a given key it never re-stages -- new items - * appended to the list are rendered immediately. + * `requestAnimationFrame`. * * @param items Full list of items to render. * @param key A session/conversation identifier. When it changes, @@ -35,67 +51,83 @@ export function useProgressiveList( key: string, options?: ProgressiveListOptions ): { staged: T[]; isStaging: boolean } { - const initialBatch = options?.initialBatch ?? DEFAULTS.initialBatch - const batchSize = options?.batchSize ?? DEFAULTS.batchSize + const initialBatch = Math.max(0, options?.initialBatch ?? DEFAULTS.initialBatch) + const batchSize = Math.max(1, options?.batchSize ?? DEFAULTS.batchSize) + const [state, setState] = useState(() => createInitialState(key, items.length, initialBatch)) + const latestItemCountRef = useRef(items.length) + + useLayoutEffect(() => { + latestItemCountRef.current = items.length + }, [items.length]) - const completedKeysRef = useRef(new Set()) - const prevKeyRef = useRef(key) - const stagingCountRef = useRef(initialBatch) - const [count, setCount] = useState(() => { - if (items.length <= initialBatch) return items.length - return initialBatch - }) + const renderState = + state.key === key && (state.count > 0 || items.length === 0 || state.caughtUp) + ? state + : createInitialState(key, items.length, initialBatch) useEffect(() => { - if (completedKeysRef.current.has(key)) { - setCount(items.length) - return - } + setState((prev) => { + if (prev.key !== key) { + return createInitialState(key, items.length, initialBatch) + } - if (items.length <= initialBatch) { - setCount(items.length) - completedKeysRef.current.add(key) - return - } + if (items.length === 0) { + if (prev.count === 0 && !prev.caughtUp) { + return prev + } + return { key, count: 0, caughtUp: false } + } - let current = Math.max(stagingCountRef.current, initialBatch) - setCount(current) + if (prev.caughtUp) { + if (prev.count === items.length) { + return prev + } + return { key, count: items.length, caughtUp: true } + } - let frame: number | undefined + const minimumCount = Math.min(items.length, initialBatch) + if (prev.count >= minimumCount && prev.count <= items.length) { + return prev + } - const step = () => { - const total = items.length - current = Math.min(total, current + batchSize) - stagingCountRef.current = current - setCount(current) - if (current >= total) { - completedKeysRef.current.add(key) - frame = undefined - return + const count = Math.min(items.length, Math.max(prev.count, minimumCount)) + return { + key, + count, + caughtUp: count >= items.length, } - frame = requestAnimationFrame(step) + }) + }, [key, items.length, initialBatch]) + + useEffect(() => { + if (state.key !== key || state.caughtUp || state.count >= items.length) { + return } - frame = requestAnimationFrame(step) + const frame = requestAnimationFrame(() => { + setState((prev) => { + if (prev.key !== key || prev.caughtUp) { + return prev + } - return () => { - if (frame !== undefined) cancelAnimationFrame(frame) - } - }, [key, items.length, initialBatch, batchSize]) + const itemCount = latestItemCountRef.current + const count = Math.min(itemCount, prev.count + batchSize) + return { + key, + count, + caughtUp: count >= itemCount, + } + }) + }) - let effectiveCount = count - if (prevKeyRef.current !== key) { - effectiveCount = items.length <= initialBatch ? items.length : initialBatch - stagingCountRef.current = initialBatch - } - prevKeyRef.current = key - - const isCompleted = completedKeysRef.current.has(key) - const isStaging = !isCompleted && effectiveCount < items.length - const staged = - isCompleted || effectiveCount >= items.length - ? items - : items.slice(Math.max(0, items.length - effectiveCount)) + return () => cancelAnimationFrame(frame) + }, [state.key, state.count, state.caughtUp, key, items.length, batchSize]) + + const effectiveCount = renderState.caughtUp + ? items.length + : Math.min(renderState.count, items.length) + const staged = items.slice(Math.max(0, items.length - effectiveCount)) + const isStaging = effectiveCount < items.length return { staged, isStaging } } diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index f9f80384c8d..8234658ddcc 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -194,6 +194,64 @@ describe('copilot go stream helpers', () => { ) }) + it('does not retry transient backend statuses because stream requests are not idempotent', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await expect( + runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + ).rejects.toMatchObject({ + name: 'CopilotBackendError', + status: 502, + body: 'bad gateway', + }) + + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('does not retry non-transient backend statuses before the SSE stream opens', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 })) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await expect( + runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + ).rejects.toThrow('Usage limit reached') + + expect(fetch).toHaveBeenCalledTimes(1) + }) + + it('does not retry network errors because Go may already be executing the request', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await expect( + runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + ).rejects.toThrow('fetch failed') + + expect(fetch).toHaveBeenCalledTimes(1) + }) + it('fails closed when the shared stream ends before a terminal event', async () => { const textEvent = createEvent({ streamId: 'stream-1', diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index a3e42f94371..c92d135affc 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -134,17 +134,27 @@ export async function runStreamLoop( requestBodyBytes, }) const fetchStart = performance.now() - const response = await fetchGo(fetchUrl, { - ...fetchOptions, - signal: abortSignal, - otelContext: options.otelContext, - spanName: `sim → go ${pathname}`, - operation: 'stream', - attributes: { - [TraceAttr.CopilotStream]: true, - ...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}), - }, - }) + let response: Response + try { + response = await fetchGo(fetchUrl, { + ...fetchOptions, + signal: abortSignal, + otelContext: options.otelContext, + spanName: `sim → go ${pathname}`, + operation: 'stream', + attributes: { + [TraceAttr.CopilotStream]: true, + ...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}), + }, + }) + } catch (error) { + fetchSpan.attributes = { + ...(fetchSpan.attributes ?? {}), + headersMs: Math.round(performance.now() - fetchStart), + } + context.trace.endSpan(fetchSpan, abortSignal?.aborted ? 'cancelled' : 'error') + throw error + } const headersElapsedMs = Math.round(performance.now() - fetchStart) fetchSpan.attributes = { ...(fetchSpan.attributes ?? {}), @@ -561,14 +571,14 @@ function stampSseReadLoopSpan( const nowWall = Date.now() const startWall = nowWall - (nowPerf - startPerfMs) - const terminalEventSeen = counters.eventsByType.complete > 0 + const terminalEventSeen = counters.eventsByType.complete > 0 || counters.eventsByType.error > 0 // `terminal_event_missing` is the single-attribute dashboard signal // for the "disappeared response" bug class: the caller considered // this leg to be the final one (`context.streamComplete === true`) - // but no `complete` event arrived on the wire. Tool-pause legs have - // expectedTerminal=false and never trip this, so dashboards can - // filter on `{ .copilot.sse.terminal_event_missing = true }` without - // false positives. + // but no terminal `complete` or `error` event arrived on the wire. + // Tool-pause legs have expectedTerminal=false and never trip this, so + // dashboards can filter on `{ .copilot.sse.terminal_event_missing = true }` + // without false positives. const terminalEventMissing = opts.expectedTerminal && !terminalEventSeen const tracer = getCopilotTracer() diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index ab16f332899..37d58624c17 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -210,6 +210,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS const abortPoller = startAbortPoller(streamId, abortController, { requestId, + chatId, }) publisher.startKeepalive() diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/copilot/request/session/abort.test.ts new file mode 100644 index 00000000000..9c6ee82aa08 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/abort.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ + +import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({ + mockHasAbortMarker: vi.fn().mockResolvedValue(false), + mockClearAbortMarker: vi.fn().mockResolvedValue(undefined), + mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/core/config/redis', () => redisConfigMock) +vi.mock('@/lib/copilot/request/session/buffer', () => ({ + hasAbortMarker: mockHasAbortMarker, + clearAbortMarker: mockClearAbortMarker, + writeAbortMarker: mockWriteAbortMarker, +})) +vi.mock('@/lib/copilot/request/otel', () => ({ + withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) => + fn({ setAttribute: vi.fn() }), +})) + +import { startAbortPoller } from '@/lib/copilot/request/session/abort' + +describe('startAbortPoller heartbeat', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockHasAbortMarker.mockResolvedValue(false) + redisConfigMockFns.mockExtendLock.mockResolvedValue(true) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('extends the chat stream lock approximately every heartbeat interval', async () => { + const controller = new AbortController() + const streamId = 'stream-heartbeat-1' + const chatId = 'chat-heartbeat-1' + + const interval = startAbortPoller(streamId, controller, { chatId }) + + try { + await vi.advanceTimersByTimeAsync(15_000) + expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(6_000) + + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenLastCalledWith( + `copilot:chat-stream-lock:${chatId}`, + streamId, + 60 + ) + + await vi.advanceTimersByTimeAsync(20_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(20_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(3) + } finally { + clearInterval(interval) + } + }) + + it('does not extend the lock when no chatId is passed (backward compat)', async () => { + const controller = new AbortController() + const interval = startAbortPoller('stream-no-chat', controller, {}) + + try { + await vi.advanceTimersByTimeAsync(90_000) + expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled() + } finally { + clearInterval(interval) + } + }) + + it('retries on the next tick when extendLock throws (no 20s backoff)', async () => { + const controller = new AbortController() + const streamId = 'stream-retry' + const chatId = 'chat-retry' + + redisConfigMockFns.mockExtendLock.mockRejectedValueOnce(new Error('redis down')) + + const interval = startAbortPoller(streamId, controller, { chatId }) + + try { + await vi.advanceTimersByTimeAsync(20_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2) + } finally { + clearInterval(interval) + } + }) + + it('stops heartbeating after ownership is lost', async () => { + const controller = new AbortController() + const streamId = 'stream-lost' + const chatId = 'chat-lost' + + redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false) + + const interval = startAbortPoller(streamId, controller, { chatId }) + + try { + await vi.advanceTimersByTimeAsync(21_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + } finally { + clearInterval(interval) + } + }) +}) diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/copilot/request/session/abort.ts index e094346adc9..db3beff57ea 100644 --- a/apps/sim/lib/copilot/request/session/abort.ts +++ b/apps/sim/lib/copilot/request/session/abort.ts @@ -5,7 +5,7 @@ import { AbortBackend } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' -import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' +import { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' import { AbortReason } from './abort-reason' import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' @@ -18,7 +18,22 @@ const pendingChatStreams = new Map< >() const DEFAULT_ABORT_POLL_MS = 1000 -const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60 + +/** + * TTL for the per-chat stream lock. Kept short so that if the Sim pod + * holding the lock dies (SIGKILL, OOM, a SIGTERM drain that doesn't + * reach the release path), the lock self-heals inside a minute rather + * than stranding the chat for hours. A live stream keeps the lock alive + * via `CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS` heartbeats. + */ +const CHAT_STREAM_LOCK_TTL_SECONDS = 60 + +/** + * Heartbeat cadence for extending the per-chat stream lock. Set to a + * third of the TTL so one missed beat still leaves room for recovery + * before the lock expires under a still-live stream. + */ +const CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS = 20_000 function registerPendingChatStream(chatId: string, streamId: string): void { let resolve!: () => void @@ -262,10 +277,14 @@ const pollingStreams = new Set() export function startAbortPoller( streamId: string, abortController: AbortController, - options?: { pollMs?: number; requestId?: string } + options?: { pollMs?: number; requestId?: string; chatId?: string } ): ReturnType { const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS const requestId = options?.requestId + const chatId = options?.chatId + + let lastHeartbeatAt = Date.now() + let heartbeatOwnershipLost = false return setInterval(() => { if (pollingStreams.has(streamId)) return @@ -287,6 +306,33 @@ export function startAbortPoller( } finally { pollingStreams.delete(streamId) } + + if (!chatId || heartbeatOwnershipLost) return + if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return + + try { + const owned = await extendLock( + getChatStreamLockKey(chatId), + streamId, + CHAT_STREAM_LOCK_TTL_SECONDS + ) + lastHeartbeatAt = Date.now() + if (!owned) { + heartbeatOwnershipLost = true + logger.warn('Lost ownership of chat stream lock — stopping heartbeat', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + }) + } + } catch (error) { + logger.warn('Failed to extend chat stream lock TTL', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + error: toError(error).message, + }) + } })() }, pollMs) } diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/copilot/request/session/contract.ts index 7953a11c956..2d116e87ce4 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/copilot/request/session/contract.ts @@ -165,6 +165,7 @@ function isStreamRef(value: unknown): value is MothershipStreamV1StreamRef { return ( isRecord(value) && typeof value.streamId === 'string' && + value.streamId.length > 0 && isOptionalString(value.chatId) && isOptionalString(value.cursor) ) diff --git a/apps/sim/lib/core/config/redis.test.ts b/apps/sim/lib/core/config/redis.test.ts index cad0051753b..b41ddb3da5b 100644 --- a/apps/sim/lib/core/config/redis.test.ts +++ b/apps/sim/lib/core/config/redis.test.ts @@ -15,6 +15,7 @@ vi.mock('ioredis', () => ({ import { closeRedisConnection, + extendLock, getRedisClient, onRedisReconnect, resetForTesting, @@ -120,6 +121,48 @@ describe('redis config', () => { }) }) + describe('extendLock', () => { + const lockKey = 'copilot:chat-stream-lock:chat-1' + const value = 'stream-abc' + const ttlSeconds = 60 + + it('returns true when the caller still owns the lock and EXPIRE succeeds', async () => { + mockRedisInstance.eval.mockResolvedValueOnce(1) + + const extended = await extendLock(lockKey, value, ttlSeconds) + + expect(extended).toBe(true) + expect(mockRedisInstance.eval).toHaveBeenCalledWith( + expect.stringContaining('expire'), + 1, + lockKey, + value, + ttlSeconds + ) + }) + + it('returns false when the value does not match (lock owned by another)', async () => { + mockRedisInstance.eval.mockResolvedValueOnce(0) + + const extended = await extendLock(lockKey, value, ttlSeconds) + + expect(extended).toBe(false) + }) + + it('returns true as a no-op when Redis is unavailable', async () => { + vi.resetModules() + vi.doMock('@/lib/core/config/env', () => + createEnvMock({ REDIS_URL: undefined as unknown as string }) + ) + const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis') + + const extended = await extendLockNoRedis(lockKey, value, ttlSeconds) + + expect(extended).toBe(true) + vi.doUnmock('@/lib/core/config/env') + }) + }) + describe('retryStrategy', () => { function captureRetryStrategy(): (times: number) => number { let capturedConfig: Record = {} diff --git a/apps/sim/lib/core/config/redis.ts b/apps/sim/lib/core/config/redis.ts index a603a2bad3b..7e60fbe8e81 100644 --- a/apps/sim/lib/core/config/redis.ts +++ b/apps/sim/lib/core/config/redis.ts @@ -136,6 +136,21 @@ else end ` +/** + * Lua script for safe lock TTL extension. + * Only refreshes the expiry if the value matches (ownership verification), + * so a stale heartbeat from a prior owner cannot extend a lock currently + * held by someone else after a TTL eviction. + * Returns 1 if the TTL was extended, 0 if not (value mismatch or key gone). + */ +const EXTEND_LOCK_SCRIPT = ` +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) +else + return 0 +end +` + /** * Acquire a distributed lock using Redis SET NX. * Returns true if lock acquired, false if already held. @@ -175,6 +190,29 @@ export async function releaseLock(lockKey: string, value: string): Promise { + const redis = getRedisClient() + if (!redis) { + return true + } + + const result = await redis.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, value, expirySeconds) + return result === 1 +} + /** * Close the Redis connection. * Use for graceful shutdown. diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 43ba1a635b7..d336add784f 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -3,11 +3,58 @@ */ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { updateRow } from '@/lib/table/service' +import { + batchInsertRows, + deleteColumn, + insertRow, + renameColumn, + replaceTableRows, + updateRow, + upsertRow, +} from '@/lib/table/service' import type { TableDefinition } from '@/lib/table/types' +import { getUniqueColumns } from '@/lib/table/validation' vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/lib/table/validation', () => ({ + validateRowSize: vi.fn(() => ({ valid: true, errors: [] })), + validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })), + validateTableName: vi.fn(() => ({ valid: true, errors: [] })), + validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })), + getUniqueColumns: vi.fn(() => []), + checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), + checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), +})) + +/** + * Inspects the queued `trx.execute(...)` calls for SQL containing `substring`. + * Works with both `sql\`...\`` (produces `{ strings, values }`) and `sql.raw(...)` + * (produces `{ rawSql }`) from the global drizzle mock. + */ +function findExecutedSqlContaining(substring: string): boolean { + return dbChainMockFns.execute.mock.calls.some(([arg]) => { + if (!arg || typeof arg !== 'object') return false + const a = arg as Record + if (Array.isArray(a.strings)) { + return (a.strings as string[]).some((s) => typeof s === 'string' && s.includes(substring)) + } + if (typeof a.rawSql === 'string') { + return (a.rawSql as string).includes(substring) + } + return false + }) +} + +function findExecutedRawSql(substring: string): string | undefined { + for (const [arg] of dbChainMockFns.execute.mock.calls) { + if (!arg || typeof arg !== 'object') continue + const raw = (arg as { rawSql?: unknown }).rawSql + if (typeof raw === 'string' && raw.includes(substring)) return raw + } + return undefined +} + const EXISTING_ROW = { id: 'row-1', tableId: 'tbl-1', @@ -106,3 +153,289 @@ describe('updateRow — partial merge', () => { ).rejects.toThrow('Row not found') }) }) + +describe('insertRow — position race safety (migration 0198 + advisory lock)', () => { + beforeEach(() => { + vi.resetAllMocks() + resetDbChainMock() + vi.mocked(getUniqueColumns).mockReturnValue([]) + }) + + it('auto-position inserts acquire the per-table advisory lock before reading max(position)', async () => { + await expect( + insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1') + ).rejects.toBeDefined() + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + expect(findExecutedSqlContaining('hashtextextended')).toBe(true) + }) + + it('explicit-position inserts also acquire the advisory lock to serialize position shifts', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'row-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'a' }, + position: 5, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await insertRow( + { tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1', position: 5 }, + TABLE, + 'req-1' + ) + + // `(table_id, position)` index is non-unique, so concurrent explicit-position + // inserts at the same slot could both skip the shift and duplicate — lock + // serializes them. + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + }) + + it('batchInsertRows acquires the advisory lock (always auto-positioned)', async () => { + await expect( + batchInsertRows( + { tableId: 'tbl-1', rows: [{ name: 'a' }, { name: 'b' }], workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + ).rejects.toBeDefined() + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + }) + + it('batchInsertRows with explicit positions acquires the advisory lock', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'row-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'a' }, + position: 3, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'row-2', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'b' }, + position: 4, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await batchInsertRows( + { + tableId: 'tbl-1', + rows: [{ name: 'a' }, { name: 'b' }], + workspaceId: 'ws-1', + positions: [3, 4], + }, + TABLE, + 'req-1' + ) + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + }) + + it('upsertRow skips the advisory lock on the update path (match found)', async () => { + vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }]) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'row-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Alice', age: 30 }, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { + id: 'row-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Alice', age: 31 }, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await upsertRow( + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Alice', age: 31 }, + conflictTarget: 'name', + }, + TABLE, + 'req-1' + ) + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false) + }) + + it('upsertRow acquires the advisory lock on the insert path (no match)', async () => { + vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }]) + // Initial existing-row check + post-lock re-check both find no match. + dbChainMockFns.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect( + upsertRow( + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Bob', age: 25 }, + conflictTarget: 'name', + }, + TABLE, + 'req-1' + ) + ).rejects.toBeDefined() + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + }) + + it('upsertRow re-checks after acquiring the lock and switches to UPDATE when a racing tx inserted the row', async () => { + vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }]) + // Initial existing-row check: no match (another tx has not committed yet). + dbChainMockFns.limit.mockResolvedValueOnce([]) + // Post-lock re-check: a racing tx just inserted the row. + const racedRow = { + id: 'row-raced', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Bob', age: 25 }, + position: 0, + createdAt: new Date(), + updatedAt: new Date(), + } + dbChainMockFns.limit.mockResolvedValueOnce([racedRow]) + // UPDATE returning the patched row. + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...racedRow, data: { name: 'Bob', age: 26 } }, + ]) + + const result = await upsertRow( + { + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Bob', age: 26 }, + conflictTarget: 'name', + }, + TABLE, + 'req-1' + ) + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + expect(result.operation).toBe('update') + expect(result.row.id).toBe('row-raced') + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) + +describe('mutation paths — SET LOCAL timeouts', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('insertRow sets the default 10s/3s/5s timeouts', async () => { + await expect( + insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1') + ).rejects.toBeDefined() + + expect(findExecutedRawSql("SET LOCAL statement_timeout = '10000ms'")).toBeDefined() + expect(findExecutedRawSql("SET LOCAL lock_timeout = '3000ms'")).toBeDefined() + expect( + findExecutedRawSql("SET LOCAL idle_in_transaction_session_timeout = '5000ms'") + ).toBeDefined() + }) + + it('batchInsertRows raises statement_timeout to 60s', async () => { + await expect( + batchInsertRows( + { tableId: 'tbl-1', rows: [{ name: 'a' }], workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + ).rejects.toBeDefined() + + expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined() + }) + + it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => { + const bigTable: TableDefinition = { ...TABLE, rowCount: 100_000, maxRows: 1_000_000 } + const payload = Array.from({ length: 50_000 }, (_, i) => ({ name: `row-${i}` })) + + await replaceTableRows( + { tableId: 'tbl-1', workspaceId: 'ws-1', rows: payload }, + bigTable, + 'req-1' + ) + + // (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap + expect(findExecutedRawSql("SET LOCAL statement_timeout = '450000ms'")).toBeDefined() + }) + + it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => { + const hugeTable: TableDefinition = { ...TABLE, rowCount: 10_000_000, maxRows: 20_000_000 } + + await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1') + + // 10M × 3ms = 30M ms, capped at 600_000ms (10 min) + expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined() + }) + + it('replaceTableRows uses the 120s floor on small tables', async () => { + const smallTable: TableDefinition = { ...TABLE, rowCount: 10 } + + await replaceTableRows( + { tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }, { name: 'b' }] }, + smallTable, + 'req-1' + ) + + // 12 × 3ms = 36ms → floored at 120_000ms + expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined() + }) + + it('renameColumn scales statement_timeout with table.rowCount', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 500_000 }]) + + await renameColumn({ tableId: 'tbl-1', oldName: 'name', newName: 'full_name' }, 'req-1') + + // 500_000 × 2ms = 1_000_000 → capped at 600_000 + expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined() + }) + + it('deleteColumn uses the 60s floor on small tables', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 100 }]) + + await deleteColumn({ tableId: 'tbl-1', columnName: 'age' }, 'req-1') + + // 100 × 2ms = 200ms → floored at 60_000ms + expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined() + }) + + it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => { + await replaceTableRows( + { tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }] }, + { ...TABLE, rowCount: 5 }, + 'req-1' + ) + + expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) + expect(findExecutedSqlContaining('hashtextextended')).toBe(true) + }) +}) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 3409e71f4a3..14dea25a75e 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -64,6 +64,80 @@ export class TableConflictError extends Error { export type TableScope = 'active' | 'archived' | 'all' +type DbTransaction = Parameters[0]>[0] + +/** + * Sets per-transaction Postgres timeouts via `SET LOCAL`. + * + * `lock_timeout` is the critical one: without it, a waiter inherits the full + * `statement_timeout` clock, so one stuck writer can drain the pool. + * + * Safe under pgBouncer transaction pooling — `SET LOCAL` is transaction-scoped + * and cleared at COMMIT/ROLLBACK before the session returns to the pool. + */ +async function setTableTxTimeouts( + trx: DbTransaction, + opts?: { statementMs?: number; lockMs?: number; idleMs?: number } +) { + const s = opts?.statementMs ?? 10_000 + const l = opts?.lockMs ?? 3_000 + const i = opts?.idleMs ?? 5_000 + await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${s}ms'`)) + await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${l}ms'`)) + await trx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${i}ms'`)) +} + +/** + * Serializes writers that compute `max(position) + 1` for the same table. + * + * The row-count trigger (migration 0198) serializes capacity via a row lock on + * `user_table_definitions` — but it fires AFTER INSERT, so two concurrent + * auto-positioned inserts can read the same snapshot and assign the same + * position (the `(table_id, position)` index is non-unique). This advisory + * lock restores the pre-trigger serialization scoped to a single table, with + * no cross-table contention. Released automatically at COMMIT/ROLLBACK. + */ +async function acquireTablePositionLock(trx: DbTransaction, tableId: string) { + await trx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))` + ) +} + +/** + * Returns the next auto-assigned `position` for a table (max(position) + 1, or 0 + * if empty). Callers must hold `acquireTablePositionLock` to avoid two concurrent + * writers computing the same value against the same snapshot. + */ +async function nextAutoPosition(trx: DbTransaction, tableId: string): Promise { + const [{ maxPos }] = await trx + .select({ + maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), + }) + .from(userTableRows) + .where(eq(userTableRows.tableId, tableId)) + return maxPos + 1 +} + +const TIMEOUT_CAP_MS = 10 * 60_000 + +/** + * Scales `statement_timeout` to the expected row-count work. + * + * Bulk operations that rewrite JSONB or cascade row triggers (e.g. + * `replaceTableRows`, `deleteColumn`, `renameColumn`) scale roughly linearly + * with row count. A fixed cap would regress large-table users who never saw a + * timeout before `SET LOCAL` was introduced. This helper picks + * `max(baseMs, rowCount * perRowMs)`, capped at 10 minutes so a single + * runaway transaction cannot indefinitely pin a pool connection. + */ +function scaledStatementTimeoutMs( + rowCount: number, + opts: { baseMs: number; perRowMs: number } +): number { + const safeRowCount = Math.max(0, rowCount) + return Math.min(TIMEOUT_CAP_MS, Math.max(opts.baseMs, safeRowCount * opts.perRowMs)) +} + /** * Gets a table by ID with full details. * @@ -88,16 +162,14 @@ export async function getTableById( archivedAt: userTableDefinitions.archivedAt, createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, - rowCount: sql`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number), + rowCount: userTableDefinitions.rowCount, }) .from(userTableDefinitions) - .leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id)) .where( includeArchived ? eq(userTableDefinitions.id, tableId) : and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt)) ) - .groupBy(userTableDefinitions.id) .limit(1) if (results.length === 0) return null @@ -156,10 +228,9 @@ export async function listTables( archivedAt: userTableDefinitions.archivedAt, createdAt: userTableDefinitions.createdAt, updatedAt: userTableDefinitions.updatedAt, - rowCount: sql`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number), + rowCount: userTableDefinitions.rowCount, }) .from(userTableDefinitions) - .leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id)) .where( scope === 'all' ? eq(userTableDefinitions.workspaceId, workspaceId) @@ -173,7 +244,6 @@ export async function listTables( isNull(userTableDefinitions.archivedAt) ) ) - .groupBy(userTableDefinitions.id) .orderBy(userTableDefinitions.createdAt) return tables.map((t) => ({ @@ -240,6 +310,7 @@ export async function createTable( // to prevent TOCTOU race on the table count limit try { await db.transaction(async (trx) => { + await setTableTxTimeouts(trx) await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`) const [{ count: existingCount }] = await trx @@ -510,6 +581,7 @@ export async function restoreTable(tableId: string, requestId: string): Promise< attemptedRestoreName = '' try { await db.transaction(async (tx) => { + await setTableTxTimeouts(tx) await tx.execute(sql`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR UPDATE`) attemptedRestoreName = await generateRestoreName(table.name, async (candidate) => { @@ -585,25 +657,22 @@ export async function insertRow( const rowId = `row_${generateId().replace(/-/g, '')}` const now = new Date() - // Atomic capacity check + insert inside a transaction. - // FOR UPDATE on the table definition row serializes concurrent inserts, - // preventing the TOCTOU race where multiple requests pass the count check. + // Capacity enforcement lives in the `increment_user_table_row_count` trigger + // (migration 0198): a single conditional UPDATE on user_table_definitions + // increments row_count iff row_count < max_rows, taking the row lock + // atomically. No app-level FOR UPDATE / COUNT needed. const [row] = await db.transaction(async (trx) => { - await trx.execute( - sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE` - ) - - const [{ count: currentCount }] = await trx - .select({ count: count() }) - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - - if (Number(currentCount) >= table.maxRows) { - throw new Error(`Table has reached maximum row limit (${table.maxRows})`) - } + await setTableTxTimeouts(trx) let targetPosition: number + // The `(table_id, position)` index is non-unique, so we serialize all + // position-aware writes (explicit and auto) through the per-table + // advisory lock. Without this, two concurrent explicit-position inserts + // at the same position can both observe an empty slot, both skip the + // shift, and each INSERT a row with a duplicate `(table_id, position)`. + await acquireTablePositionLock(trx, data.tableId) + if (data.position !== undefined) { targetPosition = data.position @@ -627,14 +696,7 @@ export async function insertRow( ) } } else { - const [{ maxPos }] = await trx - .select({ - maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), - }) - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - - targetPosition = maxPos + 1 + targetPosition = await nextAutoPosition(trx, data.tableId) } return trx @@ -706,24 +768,12 @@ export async function batchInsertRows( const now = new Date() - // Atomic capacity check + insert inside a transaction. - // FOR UPDATE on the table definition row serializes concurrent inserts. + // Capacity enforcement lives in the `increment_user_table_row_count` trigger + // (migration 0198) — fires per row and raises `Maximum row limit (%) reached ...` + // if the cap is hit mid-batch. The outer transaction means a partial batch + // rolls back cleanly. const insertedRows = await db.transaction(async (trx) => { - await trx.execute( - sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE` - ) - - const [{ count: currentCount }] = await trx - .select({ count: count() }) - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - - const remainingCapacity = table.maxRows - Number(currentCount) - if (remainingCapacity < data.rows.length) { - throw new Error( - `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${Number(currentCount)}/${table.maxRows} rows)` - ) - } + await setTableTxTimeouts(trx, { statementMs: 60_000 }) const buildRow = (rowData: RowData, position: number) => ({ id: `row_${generateId().replace(/-/g, '')}`, @@ -736,6 +786,10 @@ export async function batchInsertRows( ...(data.userId ? { createdBy: data.userId } : {}), }) + // Serialize position-aware writes per-table. See `acquireTablePositionLock` + // for why both explicit- and auto-position paths take this lock. + await acquireTablePositionLock(trx, data.tableId) + if (data.positions && data.positions.length > 0) { // Position-aware insert: shift existing rows to create gaps, then insert. // Process positions ascending so each shift preserves gaps created by prior shifts. @@ -755,14 +809,8 @@ export async function batchInsertRows( return trx.insert(userTableRows).values(rowsToInsert).returning() } - const [{ maxPos }] = await trx - .select({ - maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), - }) - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - - const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, maxPos + 1 + i)) + const startPos = await nextAutoPosition(trx, data.tableId) + const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, startPos + i)) return trx.insert(userTableRows).values(rowsToInsert).returning() }) @@ -849,10 +897,21 @@ export async function replaceTableRows( const now = new Date() + const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length + const statementMs = scaledStatementTimeoutMs(totalRowWork, { + baseMs: 120_000, + perRowMs: 3, + }) + const result = await db.transaction(async (trx) => { - await trx.execute( - sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE` - ) + await setTableTxTimeouts(trx, { statementMs }) + + // Serialize concurrent replaces (and concurrent auto-position inserts) on the + // same table. Without this, two concurrent replaces each see their own MVCC + // snapshot for the DELETE; the second's DELETE would not observe rows the + // first inserted, so both transactions commit and the table ends up with + // the union of both row sets instead of only the last caller's rows. + await acquireTablePositionLock(trx, data.tableId) const deletedRows = await trx .delete(userTableRows) @@ -897,8 +956,11 @@ export async function replaceTableRows( * column, otherwise inserts a new row. * * Uses a single unique column for matching (not OR across all unique columns) to avoid - * ambiguous matches when multiple unique columns exist. Capacity checks run inside the - * transaction with a FOR UPDATE lock to prevent TOCTOU races. + * ambiguous matches when multiple unique columns exist. Capacity enforcement lives + * in the `increment_user_table_row_count` trigger (migration 0198). On the insert + * path we acquire the per-table advisory lock and re-check for an existing match + * before inserting, so a concurrent upsert racing on the same conflict target + * cannot produce a duplicate row. * * @param data - Upsert data including optional conflictTarget * @param table - Table definition @@ -965,12 +1027,10 @@ export async function upsertRow( ? sql`${userTableRows.data}->>${sql.raw(`'${targetColumnName}'`)} = ${String(targetValue)}` : sql`(${userTableRows.data}->${sql.raw(`'${targetColumnName}'`)})::jsonb = ${JSON.stringify(targetValue)}::jsonb` - // Entire upsert runs in a transaction with FOR UPDATE lock on the table definition. - // This serializes concurrent upserts and prevents the TOCTOU race on row count. + // Capacity enforcement for the insert path lives in the `increment_user_table_row_count` + // trigger (migration 0198). The update path doesn't change row_count, so no check needed. const result = await db.transaction(async (trx) => { - await trx.execute( - sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE` - ) + await setTableTxTimeouts(trx) // Find existing row by single conflict target column const [existingRow] = await trx @@ -998,14 +1058,33 @@ export async function upsertRow( const now = new Date() - if (existingRow) { + // Resolve which row (if any) we should update. If the initial SELECT missed, + // acquire the lock and re-check — a concurrent upsert may have inserted the + // matching row between our SELECT and the INSERT path, and without the + // re-check both transactions would insert and produce a duplicate that + // bypasses the app-level unique check. + let matchedRowId = existingRow?.id + if (!matchedRowId) { + await acquireTablePositionLock(trx, data.tableId) + const [racedRow] = await trx + .select({ id: userTableRows.id }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId), + matchFilter + ) + ) + .limit(1) + matchedRowId = racedRow?.id + } + + if (matchedRowId) { const [updatedRow] = await trx .update(userTableRows) - .set({ - data: data.data, - updatedAt: now, - }) - .where(eq(userTableRows.id, existingRow.id)) + .set({ data: data.data, updatedAt: now }) + .where(eq(userTableRows.id, matchedRowId)) .returning() return { @@ -1020,23 +1099,6 @@ export async function upsertRow( } } - // Check capacity atomically (inside the lock) - const [{ count: currentCount }] = await trx - .select({ count: count() }) - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - - if (Number(currentCount) >= table.maxRows) { - throw new Error(`Table row limit reached (${table.maxRows} rows max)`) - } - - const [{ maxPos }] = await trx - .select({ - maxPos: sql`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number), - }) - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - const [insertedRow] = await trx .insert(userTableRows) .values({ @@ -1044,7 +1106,7 @@ export async function upsertRow( tableId: data.tableId, workspaceId: data.workspaceId, data: data.data, - position: maxPos + 1, + position: await nextAutoPosition(trx, data.tableId), createdAt: now, updatedAt: now, ...(data.userId ? { createdBy: data.userId } : {}), @@ -1073,6 +1135,14 @@ export async function upsertRow( /** * Queries rows from a table with filtering, sorting, and pagination. * + * Filter cost model: equality filters (`$eq`, `$in`) compile to JSONB + * containment (`@>`) and hit the GIN (jsonb_path_ops) index on + * `user_table_rows.data`. Range operators (`$gt`, `$gte`, `$lt`, `$lte`) and + * `$contains` compile to `data->>'field'` text extraction and bypass the GIN + * index — they fall back to a sequential scan of the rows for the table + * (bounded only by the btree on `table_id`). Prefer equality on hot paths; set + * `includeTotal: false` when the caller does not need the `COUNT(*)`. + * * @param tableId - Table ID to query * @param workspaceId - Workspace ID for access control * @param options - Query options (filter, sort, limit, offset) @@ -1085,7 +1155,13 @@ export async function queryRows( options: QueryOptions, requestId: string ): Promise { - const { filter, sort, limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, offset = 0 } = options + const { + filter, + sort, + limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, + offset = 0, + includeTotal = true, + } = options const tableName = USER_TABLE_ROWS_SQL_NAME @@ -1103,13 +1179,14 @@ export async function queryRows( } } - // Get total count - const countResult = await db - .select({ count: count() }) - .from(userTableRows) - .where(whereClause ?? baseConditions) - - const totalCount = Number(countResult[0].count) + let totalCount: number | null = null + if (includeTotal) { + const countResult = await db + .select({ count: count() }) + .from(userTableRows) + .where(whereClause ?? baseConditions) + totalCount = Number(countResult[0].count) + } // Build ORDER BY clause (default to position ASC for stable ordering) let orderByClause @@ -1273,6 +1350,7 @@ export async function deleteRow( requestId: string ): Promise { await db.transaction(async (trx) => { + await setTableTxTimeouts(trx) const [deleted] = await trx .delete(userTableRows) .where( @@ -1351,49 +1429,45 @@ export async function updateRowsByFilter( } const uniqueColumns = getUniqueColumns(table.schema) - if (uniqueColumns.length > 0) { + const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) + if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { - const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) - if (uniqueColumnsInUpdate.length > 0) { - throw new Error( - `Cannot set unique column values when updating multiple rows. ` + - `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + - `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` - ) - } + throw new Error( + `Cannot set unique column values when updating multiple rows. ` + + `Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` + + `Updating ${matchingRows.length} rows with the same value would violate uniqueness.` + ) } - for (const row of matchingRows) { - const existingData = row.data as RowData - const mergedData = { ...existingData, ...data.data } - const uniqueValidation = await checkUniqueConstraintsDb( - data.tableId, - mergedData, - table.schema, - row.id - ) - if (!uniqueValidation.valid) { - throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) - } + // Only one row — only the touched unique columns need re-checking. + const row = matchingRows[0] + const mergedData = { ...(row.data as RowData), ...data.data } + const uniqueValidation = await checkUniqueConstraintsDb( + data.tableId, + mergedData, + table.schema, + row.id + ) + if (!uniqueValidation.valid) { + throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`) } } const now = new Date() + const ids = matchingRows.map((r) => r.id) + const patchJson = JSON.stringify(data.data) await db.transaction(async (trx) => { - for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { - const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) - const updatePromises = batch.map((row) => { - const existingData = row.data as RowData - return trx - .update(userTableRows) - .set({ - data: { ...existingData, ...data.data }, - updatedAt: now, - }) - .where(eq(userTableRows.id, row.id)) - }) - await Promise.all(updatePromises) + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + for (let i = 0; i < ids.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { + const batchIds = ids.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) + await trx + .update(userTableRows) + .set({ + data: sql`${userTableRows.data} || ${patchJson}::jsonb`, + updatedAt: now, + }) + .where(inArray(userTableRows.id, batchIds)) } }) @@ -1401,7 +1475,7 @@ export async function updateRowsByFilter( return { affectedCount: matchingRows.length, - affectedRowIds: matchingRows.map((r) => r.id), + affectedRowIds: ids, } } @@ -1473,6 +1547,7 @@ export async function batchUpdateRows( const now = new Date() await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) const updatePromises = batch.map(({ rowId, mergedData }) => @@ -1493,20 +1568,38 @@ export async function batchUpdateRows( } } -type DbTransaction = Parameters[0]>[0] - /** - * Recompacts row positions to be contiguous (0, 1, 2, ...) after batch deletions. + * Recompacts row positions to be contiguous after batch deletions. + * + * When `minDeletedPos` is provided, only rows with `position >= minDeletedPos` + * are re-numbered (starting from `minDeletedPos`). Rows before the earliest + * deleted position are untouched since their position is unaffected. + * + * If `minDeletedPos` is omitted, the whole table is recompacted from 0. * Single-row deletes use the more efficient `position - 1` shift in {@link deleteRow}. */ -async function recompactPositions(tableId: string, trx: DbTransaction) { +async function recompactPositions(tableId: string, trx: DbTransaction, minDeletedPos?: number) { + if (minDeletedPos === undefined) { + await trx.execute(sql` + UPDATE user_table_rows t + SET position = r.new_pos + FROM ( + SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos + FROM user_table_rows + WHERE table_id = ${tableId} + ) r + WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos + `) + return + } + await trx.execute(sql` UPDATE user_table_rows t SET position = r.new_pos FROM ( - SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos + SELECT id, ${minDeletedPos}::int + ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos FROM user_table_rows - WHERE table_id = ${tableId} + WHERE table_id = ${tableId} AND position >= ${minDeletedPos} ) r WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos `) @@ -1538,7 +1631,7 @@ export async function deleteRowsByFilter( ) let query = db - .select({ id: userTableRows.id }) + .select({ id: userTableRows.id, position: userTableRows.position }) .from(userTableRows) .where(and(baseConditions, filterClause)) @@ -1553,8 +1646,13 @@ export async function deleteRowsByFilter( } const rowIds = matchingRows.map((r) => r.id) + const minDeletedPos = matchingRows.reduce( + (min, r) => (r.position < min ? r.position : min), + matchingRows[0].position + ) await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs: 60_000 }) for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) await trx.delete(userTableRows).where( @@ -1569,7 +1667,7 @@ export async function deleteRowsByFilter( ) } - await recompactPositions(data.tableId, trx) + await recompactPositions(data.tableId, trx, minDeletedPos) }) logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${data.tableId}`) @@ -1594,7 +1692,8 @@ export async function deleteRowsByIds( const uniqueRequestedRowIds = Array.from(new Set(data.rowIds)) const deletedRows = await db.transaction(async (trx) => { - const deleted: { id: string }[] = [] + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + const deleted: { id: string; position: number }[] = [] for (let i = 0; i < uniqueRequestedRowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) { const batch = uniqueRequestedRowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE) const rows = await trx @@ -1609,11 +1708,17 @@ export async function deleteRowsByIds( )}])` ) ) - .returning({ id: userTableRows.id }) + .returning({ id: userTableRows.id, position: userTableRows.position }) deleted.push(...rows) } - await recompactPositions(data.tableId, trx) + if (deleted.length > 0) { + const minDeletedPos = deleted.reduce( + (min, r) => (r.position < min ? r.position : min), + deleted[0].position + ) + await recompactPositions(data.tableId, trx, minDeletedPos) + } return deleted }) @@ -1691,8 +1796,13 @@ export async function renameColumn( } const now = new Date() + const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs }) await trx .update(userTableDefinitions) .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) @@ -1752,8 +1862,13 @@ export async function deleteColumn( } const now = new Date() + const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs }) await trx .update(userTableDefinitions) .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) @@ -1815,8 +1930,13 @@ export async function deleteColumns( } const now = new Date() + const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2 * namesToDelete.size, + }) await db.transaction(async (trx) => { + await setTableTxTimeouts(trx, { statementMs }) await trx .update(userTableDefinitions) .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 2fea3559b20..d2004175f44 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -30,6 +30,14 @@ const ALLOWED_OPERATORS = new Set([ * Builds a WHERE clause from a filter object. * Recursively processes logical operators ($or, $and) and field conditions. * + * Index behavior: equality ($eq, $in) uses the JSONB containment operator (@>) and + * can leverage the GIN index on `user_table_rows.data` (jsonb_path_ops). Range + * operators ($gt, $gte, $lt, $lte) and pattern match ($contains) fall back to + * text extraction via `data->>'field'`, which defeats the GIN index and produces + * a sequential scan over the table's rows (bounded by a btree prefix on + * `table_id`). Prefer equality filters on hot paths; assume range filters are + * O(rows per table) until a per-column expression index is added. + * * @param filter - Filter object with field conditions and logical operators * @param tableName - Table name for the query (e.g., 'user_table_rows') * @returns SQL WHERE clause or undefined if no filter specified diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 6b361d24310..070ff5771fa 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -139,12 +139,18 @@ export interface QueryOptions { sort?: Sort limit?: number offset?: number + /** + * When true (default), runs a `COUNT(*)` and returns `totalCount` as a number. + * Pass `false` to skip the count query (grid UI doesn't need it); `totalCount` + * is returned as `null` to signal it was not computed. + */ + includeTotal?: boolean } export interface QueryResult { rows: TableRow[] rowCount: number - totalCount: number + totalCount: number | null limit: number offset: number } diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts index 0580a899b8f..f75fa58b00a 100644 --- a/apps/sim/lib/webhooks/providers/ashby.ts +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -2,16 +2,18 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' +import { NextResponse } from 'next/server' import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' import type { + AuthContext, DeleteSubscriptionContext, + EventMatchContext, FormatInputContext, FormatInputResult, SubscriptionContext, SubscriptionResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { createHmacVerifier } from '@/lib/webhooks/providers/utils' const logger = createLogger('WebhookProvider:Ashby') @@ -48,17 +50,74 @@ export const ashbyHandler: WebhookProviderHandler = { input: { ...((b.data as Record) || {}), action: b.action, - data: b.data || {}, }, } }, - verifyAuth: createHmacVerifier({ - configKey: 'secretToken', - headerName: 'ashby-signature', - validateFn: validateAshbySignature, - providerLabel: 'Ashby', - }), + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { + const secretToken = (providerConfig.secretToken as string | undefined)?.trim() + if (!secretToken) { + logger.warn( + `[${requestId}] Ashby webhook missing secretToken in providerConfig — rejecting request` + ) + return new NextResponse( + 'Unauthorized - Ashby webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.', + { status: 401 } + ) + } + + const signature = request.headers.get('ashby-signature') + if (!signature) { + logger.warn(`[${requestId}] Ashby webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Ashby signature', { status: 401 }) + } + + if (!validateAshbySignature(secretToken, signature, rawBody)) { + logger.warn(`[${requestId}] Ashby signature verification failed`, { + signatureLength: signature.length, + secretLength: secretToken.length, + }) + return new NextResponse('Unauthorized - Invalid Ashby signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ + webhook, + body, + requestId, + providerConfig, + }: EventMatchContext): Promise { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + const action = typeof obj?.action === 'string' ? obj.action : '' + + if (action === 'ping') { + logger.debug(`[${requestId}] Ashby ping event received. Skipping execution.`, { + webhookId: webhook.id, + triggerId, + }) + return false + } + + if (!triggerId) return true + + const { isAshbyEventMatch } = await import('@/triggers/ashby/utils') + if (!isAshbyEventMatch(triggerId, action)) { + logger.debug( + `[${requestId}] Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`, + { + webhookId: webhook.id, + triggerId, + receivedAction: action, + } + ) + return false + } + + return true + }, async createSubscription(ctx: SubscriptionContext): Promise { try { @@ -78,18 +137,12 @@ export const ashbyHandler: WebhookProviderHandler = { throw new Error('Trigger ID is required to create Ashby webhook.') } - const webhookTypeMap: Record = { - ashby_application_submit: 'applicationSubmit', - ashby_candidate_stage_change: 'candidateStageChange', - ashby_candidate_hire: 'candidateHire', - ashby_candidate_delete: 'candidateDelete', - ashby_job_create: 'jobCreate', - ashby_offer_create: 'offerCreate', - } - - const webhookType = webhookTypeMap[triggerId] + const { ASHBY_TRIGGER_ACTION_MAP } = await import('@/triggers/ashby/utils') + const webhookType = ASHBY_TRIGGER_ACTION_MAP[triggerId] if (!webhookType) { - throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) + throw new Error( + `Unknown Ashby triggerId: ${triggerId}. Add it to ASHBY_TRIGGER_ACTION_MAP.` + ) } const notificationUrl = getNotificationUrl(ctx.webhook) diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index bc6ebdd465f..8161b7d2432 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -34,6 +34,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { ashby: { emailType: '_removed_emailType', phoneType: '_removed_phoneType', + filterCandidateId: '_removed_filterCandidateId', }, rippling: { action: '_removed_action', diff --git a/apps/sim/tools/ashby/add_candidate_tag.ts b/apps/sim/tools/ashby/add_candidate_tag.ts index 35120a5802d..e013cf63be1 100644 --- a/apps/sim/tools/ashby/add_candidate_tag.ts +++ b/apps/sim/tools/ashby/add_candidate_tag.ts @@ -1,3 +1,5 @@ +import type { AshbyCandidate } from '@/tools/ashby/types' +import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyAddCandidateTagParams { @@ -7,9 +9,7 @@ interface AshbyAddCandidateTagParams { } interface AshbyAddCandidateTagResponse extends ToolResponse { - output: { - success: boolean - } + output: AshbyCandidate } export const addCandidateTagTool: ToolConfig< @@ -18,7 +18,7 @@ export const addCandidateTagTool: ToolConfig< > = { id: 'ashby_add_candidate_tag', name: 'Ashby Add Candidate Tag', - description: 'Adds a tag to a candidate in Ashby.', + description: 'Adds a tag to a candidate in Ashby and returns the updated candidate.', version: '1.0.0', params: { @@ -50,8 +50,8 @@ export const addCandidateTagTool: ToolConfig< Authorization: `Basic ${btoa(`${params.apiKey}:`)}`, }), body: (params) => ({ - candidateId: params.candidateId, - tagId: params.tagId, + candidateId: params.candidateId.trim(), + tagId: params.tagId.trim(), }), }, @@ -64,13 +64,9 @@ export const addCandidateTagTool: ToolConfig< return { success: true, - output: { - success: true, - }, + output: mapCandidate(data.results), } }, - outputs: { - success: { type: 'boolean', description: 'Whether the tag was successfully added' }, - }, + outputs: CANDIDATE_OUTPUTS, } diff --git a/apps/sim/tools/ashby/change_application_stage.ts b/apps/sim/tools/ashby/change_application_stage.ts index 6de88e6cd8f..c573b04df3e 100644 --- a/apps/sim/tools/ashby/change_application_stage.ts +++ b/apps/sim/tools/ashby/change_application_stage.ts @@ -1,3 +1,5 @@ +import type { AshbyApplication } from '@/tools/ashby/types' +import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyChangeApplicationStageParams { @@ -8,10 +10,7 @@ interface AshbyChangeApplicationStageParams { } interface AshbyChangeApplicationStageResponse extends ToolResponse { - output: { - applicationId: string - stageId: string | null - } + output: AshbyApplication } export const changeApplicationStageTool: ToolConfig< @@ -61,10 +60,10 @@ export const changeApplicationStageTool: ToolConfig< }), body: (params) => { const body: Record = { - applicationId: params.applicationId, - interviewStageId: params.interviewStageId, + applicationId: params.applicationId.trim(), + interviewStageId: params.interviewStageId.trim(), } - if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId + if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId.trim() return body }, }, @@ -76,19 +75,11 @@ export const changeApplicationStageTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to change application stage') } - const r = data.results - return { success: true, - output: { - applicationId: r.id ?? null, - stageId: r.currentInterviewStage?.id ?? null, - }, + output: mapApplication(data.results), } }, - outputs: { - applicationId: { type: 'string', description: 'Application UUID' }, - stageId: { type: 'string', description: 'New interview stage UUID' }, - }, + outputs: APPLICATION_OUTPUTS, } diff --git a/apps/sim/tools/ashby/create_application.ts b/apps/sim/tools/ashby/create_application.ts index b4bff1e5203..5482446de7f 100644 --- a/apps/sim/tools/ashby/create_application.ts +++ b/apps/sim/tools/ashby/create_application.ts @@ -1,3 +1,5 @@ +import type { AshbyApplication } from '@/tools/ashby/types' +import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyCreateApplicationParams { @@ -12,9 +14,7 @@ interface AshbyCreateApplicationParams { } interface AshbyCreateApplicationResponse extends ToolResponse { - output: { - applicationId: string - } + output: AshbyApplication } export const createApplicationTool: ToolConfig< @@ -88,13 +88,13 @@ export const createApplicationTool: ToolConfig< }), body: (params) => { const body: Record = { - candidateId: params.candidateId, - jobId: params.jobId, + candidateId: params.candidateId.trim(), + jobId: params.jobId.trim(), } - if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId - if (params.interviewStageId) body.interviewStageId = params.interviewStageId - if (params.sourceId) body.sourceId = params.sourceId - if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId + if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId.trim() + if (params.interviewStageId) body.interviewStageId = params.interviewStageId.trim() + if (params.sourceId) body.sourceId = params.sourceId.trim() + if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId.trim() if (params.createdAt) body.createdAt = params.createdAt return body }, @@ -107,17 +107,11 @@ export const createApplicationTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to create application') } - const r = data.results - return { success: true, - output: { - applicationId: r.applicationId ?? null, - }, + output: mapApplication(data.results), } }, - outputs: { - applicationId: { type: 'string', description: 'Created application UUID' }, - }, + outputs: APPLICATION_OUTPUTS, } diff --git a/apps/sim/tools/ashby/create_candidate.ts b/apps/sim/tools/ashby/create_candidate.ts index cda7bb40aaf..49d58342a3e 100644 --- a/apps/sim/tools/ashby/create_candidate.ts +++ b/apps/sim/tools/ashby/create_candidate.ts @@ -1,5 +1,6 @@ +import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from '@/tools/ashby/types' +import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils' import type { ToolConfig } from '@/tools/types' -import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from './types' export const createCandidateTool: ToolConfig< AshbyCreateCandidateParams, @@ -25,7 +26,7 @@ export const createCandidateTool: ToolConfig< }, email: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', description: 'Primary email address for the candidate', }, @@ -65,12 +66,12 @@ export const createCandidateTool: ToolConfig< body: (params) => { const body: Record = { name: params.name, - email: params.email, } + if (params.email) body.email = params.email if (params.phoneNumber) body.phoneNumber = params.phoneNumber if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl if (params.githubUrl) body.githubUrl = params.githubUrl - if (params.sourceId) body.sourceId = params.sourceId + if (params.sourceId) body.sourceId = params.sourceId.trim() return body }, }, @@ -82,55 +83,11 @@ export const createCandidateTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to create candidate') } - const r = data.results - return { success: true, - output: { - id: r.id ?? null, - name: r.name ?? null, - primaryEmailAddress: r.primaryEmailAddress - ? { - value: r.primaryEmailAddress.value ?? '', - type: r.primaryEmailAddress.type ?? 'Other', - isPrimary: r.primaryEmailAddress.isPrimary ?? true, - } - : null, - primaryPhoneNumber: r.primaryPhoneNumber - ? { - value: r.primaryPhoneNumber.value ?? '', - type: r.primaryPhoneNumber.type ?? 'Other', - isPrimary: r.primaryPhoneNumber.isPrimary ?? true, - } - : null, - createdAt: r.createdAt ?? null, - }, + output: mapCandidate(data.results), } }, - outputs: { - id: { type: 'string', description: 'Created candidate UUID' }, - name: { type: 'string', description: 'Full name' }, - primaryEmailAddress: { - type: 'object', - description: 'Primary email contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Email address' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary email' }, - }, - }, - primaryPhoneNumber: { - type: 'object', - description: 'Primary phone contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Phone number' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' }, - }, - }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - }, + outputs: CANDIDATE_OUTPUTS, } diff --git a/apps/sim/tools/ashby/create_note.ts b/apps/sim/tools/ashby/create_note.ts index 594efb24d91..03e1ec15546 100644 --- a/apps/sim/tools/ashby/create_note.ts +++ b/apps/sim/tools/ashby/create_note.ts @@ -1,5 +1,5 @@ +import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from '@/tools/ashby/types' import type { ToolConfig } from '@/tools/types' -import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from './types' export const createNoteTool: ToolConfig = { id: 'ashby_create_note', @@ -51,7 +51,7 @@ export const createNoteTool: ToolConfig { const body: Record = { - candidateId: params.candidateId, + candidateId: params.candidateId.trim(), sendNotifications: params.sendNotifications ?? false, } if (params.noteType === 'text/html') { @@ -74,16 +74,42 @@ export const createNoteTool: ToolConfig ({ - applicationId: params.applicationId, + applicationId: params.applicationId.trim(), }), }, @@ -80,98 +54,11 @@ export const getApplicationTool: ToolConfig< throw new Error(data.errorInfo?.message || 'Failed to get application') } - const r = data.results - return { success: true, - output: { - id: r.id ?? null, - status: r.status ?? null, - candidate: { - id: r.candidate?.id ?? null, - name: r.candidate?.name ?? null, - }, - job: { - id: r.job?.id ?? null, - title: r.job?.title ?? null, - }, - currentInterviewStage: r.currentInterviewStage - ? { - id: r.currentInterviewStage.id ?? null, - title: r.currentInterviewStage.title ?? null, - type: r.currentInterviewStage.type ?? null, - } - : null, - source: r.source - ? { - id: r.source.id ?? null, - title: r.source.title ?? null, - } - : null, - archiveReason: r.archiveReason - ? { - id: r.archiveReason.id ?? null, - text: r.archiveReason.text ?? null, - reasonType: r.archiveReason.reasonType ?? null, - } - : null, - archivedAt: r.archivedAt ?? null, - createdAt: r.createdAt ?? null, - updatedAt: r.updatedAt ?? null, - }, + output: mapApplication(data.results), } }, - outputs: { - id: { type: 'string', description: 'Application UUID' }, - status: { type: 'string', description: 'Application status (Active, Hired, Archived, Lead)' }, - candidate: { - type: 'object', - description: 'Associated candidate', - properties: { - id: { type: 'string', description: 'Candidate UUID' }, - name: { type: 'string', description: 'Candidate name' }, - }, - }, - job: { - type: 'object', - description: 'Associated job', - properties: { - id: { type: 'string', description: 'Job UUID' }, - title: { type: 'string', description: 'Job title' }, - }, - }, - currentInterviewStage: { - type: 'object', - description: 'Current interview stage', - optional: true, - properties: { - id: { type: 'string', description: 'Stage UUID' }, - title: { type: 'string', description: 'Stage title' }, - type: { type: 'string', description: 'Stage type' }, - }, - }, - source: { - type: 'object', - description: 'Application source', - optional: true, - properties: { - id: { type: 'string', description: 'Source UUID' }, - title: { type: 'string', description: 'Source title' }, - }, - }, - archiveReason: { - type: 'object', - description: 'Reason for archival', - optional: true, - properties: { - id: { type: 'string', description: 'Reason UUID' }, - text: { type: 'string', description: 'Reason text' }, - reasonType: { type: 'string', description: 'Reason type' }, - }, - }, - archivedAt: { type: 'string', description: 'ISO 8601 archive timestamp', optional: true }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' }, - }, + outputs: APPLICATION_OUTPUTS, } diff --git a/apps/sim/tools/ashby/get_candidate.ts b/apps/sim/tools/ashby/get_candidate.ts index 6fcbe86d259..c6aed78aa4e 100644 --- a/apps/sim/tools/ashby/get_candidate.ts +++ b/apps/sim/tools/ashby/get_candidate.ts @@ -1,5 +1,6 @@ +import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from '@/tools/ashby/types' +import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils' import type { ToolConfig } from '@/tools/types' -import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from './types' export const getCandidateTool: ToolConfig = { id: 'ashby_get_candidate', @@ -30,7 +31,7 @@ export const getCandidateTool: ToolConfig ({ - candidateId: params.candidateId.trim(), + id: params.candidateId.trim(), }), }, @@ -41,94 +42,11 @@ export const getCandidateTool: ToolConfig l.type === 'LinkedIn')?.url ?? null, - githubUrl: - (r.socialLinks ?? []).find((l: { type: string }) => l.type === 'GitHub')?.url ?? null, - tags: (r.tags ?? []).map((t: { id: string; title: string }) => ({ - id: t.id, - title: t.title, - })), - applicationIds: r.applicationIds ?? [], - createdAt: r.createdAt ?? null, - updatedAt: r.updatedAt ?? null, - }, + output: mapCandidate(data.results), } }, - outputs: { - id: { type: 'string', description: 'Candidate UUID' }, - name: { type: 'string', description: 'Full name' }, - primaryEmailAddress: { - type: 'object', - description: 'Primary email contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Email address' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary email' }, - }, - }, - primaryPhoneNumber: { - type: 'object', - description: 'Primary phone contact info', - optional: true, - properties: { - value: { type: 'string', description: 'Phone number' }, - type: { type: 'string', description: 'Contact type (Personal, Work, Other)' }, - isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' }, - }, - }, - profileUrl: { - type: 'string', - description: 'URL to the candidate Ashby profile', - optional: true, - }, - position: { type: 'string', description: 'Current position or title', optional: true }, - company: { type: 'string', description: 'Current company', optional: true }, - linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true }, - githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true }, - tags: { - type: 'array', - description: 'Tags applied to the candidate', - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'Tag UUID' }, - title: { type: 'string', description: 'Tag title' }, - }, - }, - }, - applicationIds: { - type: 'array', - description: 'IDs of associated applications', - items: { type: 'string', description: 'Application UUID' }, - }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' }, - }, + outputs: CANDIDATE_OUTPUTS, } diff --git a/apps/sim/tools/ashby/get_job.ts b/apps/sim/tools/ashby/get_job.ts index 68b0827b3c3..03a7b1c9087 100644 --- a/apps/sim/tools/ashby/get_job.ts +++ b/apps/sim/tools/ashby/get_job.ts @@ -1,5 +1,6 @@ +import type { AshbyGetJobParams, AshbyGetJobResponse } from '@/tools/ashby/types' +import { JOB_OUTPUTS, mapJob } from '@/tools/ashby/utils' import type { ToolConfig } from '@/tools/types' -import type { AshbyGetJobParams, AshbyGetJobResponse } from './types' export const getJobTool: ToolConfig = { id: 'ashby_get_job', @@ -30,7 +31,7 @@ export const getJobTool: ToolConfig = { Authorization: `Basic ${btoa(`${params.apiKey}:`)}`, }), body: (params) => ({ - jobId: params.jobId.trim(), + id: params.jobId.trim(), }), }, @@ -41,43 +42,11 @@ export const getJobTool: ToolConfig = { throw new Error(data.errorInfo?.message || 'Failed to get job') } - const r = data.results - return { success: true, - output: { - id: r.id ?? null, - title: r.title ?? null, - status: r.status ?? null, - employmentType: r.employmentType ?? null, - departmentId: r.departmentId ?? null, - locationId: r.locationId ?? null, - descriptionPlain: r.descriptionPlain ?? null, - isArchived: r.isArchived ?? false, - createdAt: r.createdAt ?? null, - updatedAt: r.updatedAt ?? null, - }, + output: mapJob(data.results), } }, - outputs: { - id: { type: 'string', description: 'Job UUID' }, - title: { type: 'string', description: 'Job title' }, - status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' }, - employmentType: { - type: 'string', - description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)', - optional: true, - }, - departmentId: { type: 'string', description: 'Department UUID', optional: true }, - locationId: { type: 'string', description: 'Location UUID', optional: true }, - descriptionPlain: { - type: 'string', - description: 'Job description in plain text', - optional: true, - }, - isArchived: { type: 'boolean', description: 'Whether the job is archived' }, - createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' }, - updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' }, - }, + outputs: JOB_OUTPUTS, } diff --git a/apps/sim/tools/ashby/get_job_posting.ts b/apps/sim/tools/ashby/get_job_posting.ts index 14b39dfa34d..fc0d973c549 100644 --- a/apps/sim/tools/ashby/get_job_posting.ts +++ b/apps/sim/tools/ashby/get_job_posting.ts @@ -3,20 +3,80 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' interface AshbyGetJobPostingParams { apiKey: string jobPostingId: string + expandApplicationFormDefinition?: boolean + expandSurveyFormDefinitions?: boolean +} + +interface AshbyDescriptionPart { + html: string | null + plain: string | null +} + +interface AshbyJobPosting { + id: string + title: string + descriptionPlain: string | null + descriptionHtml: string | null + descriptionSocial: string | null + descriptionParts: { + descriptionOpening: AshbyDescriptionPart | null + descriptionBody: AshbyDescriptionPart | null + descriptionClosing: AshbyDescriptionPart | null + } | null + departmentName: string | null + teamName: string | null + teamNameHierarchy: string[] + jobId: string | null + locationName: string | null + locationIds: { + primaryLocationId: string | null + secondaryLocationIds: string[] + } | null + address: { + postalAddress: { + addressCountry: string | null + addressRegion: string | null + addressLocality: string | null + postalCode: string | null + streetAddress: string | null + } | null + } | null + isRemote: boolean + workplaceType: string | null + employmentType: string | null + isListed: boolean + suppressDescriptionOpening: boolean + suppressDescriptionClosing: boolean + publishedDate: string | null + applicationDeadline: string | null + externalLink: string | null + applyLink: string | null + compensation: { + compensationTierSummary: string | null + summaryComponents: Array<{ + summary: string | null + compensationTypeLabel: string | null + interval: string | null + currencyCode: string | null + minValue: number | null + maxValue: number | null + }> + shouldDisplayCompensationOnJobBoard: boolean + } | null + applicationLimitCalloutHtml: string | null + updatedAt: string | null } interface AshbyGetJobPostingResponse extends ToolResponse { - output: { - id: string - title: string - jobId: string | null - locationName: string | null - departmentName: string | null - employmentType: string | null - descriptionPlain: string | null - isListed: boolean - publishedDate: string | null - externalLink: string | null + output: AshbyJobPosting +} + +function mapDescriptionPart(raw: unknown): AshbyDescriptionPart | null { + if (!raw || typeof raw !== 'object') return null + const p = raw as Record + return { + html: (p.html as string) ?? null, + plain: (p.plain as string) ?? null, } } @@ -39,6 +99,18 @@ export const getJobPostingTool: ToolConfig ({ - jobPostingId: params.jobPostingId, - }), + body: (params) => { + const body: Record = { + jobPostingId: params.jobPostingId.trim(), + } + if (params.expandApplicationFormDefinition !== undefined) { + body.expandApplicationFormDefinition = params.expandApplicationFormDefinition + } + if (params.expandSurveyFormDefinitions !== undefined) { + body.expandSurveyFormDefinitions = params.expandSurveyFormDefinitions + } + return body + }, }, transformResponse: async (response: Response) => { @@ -60,21 +141,90 @@ export const getJobPostingTool: ToolConfig & { + descriptionParts?: Record + locationIds?: { primaryLocationId?: string; secondaryLocationIds?: string[] } + address?: { postalAddress?: Record } + compensation?: Record & { + summaryComponents?: Array> + } + } + + const pa = r.address?.postalAddress + const comp = r.compensation + const summaryComponents = Array.isArray(comp?.summaryComponents) ? comp.summaryComponents : [] + const descParts = r.descriptionParts return { success: true, output: { - id: r.id ?? null, - title: r.jobTitle ?? r.title ?? null, - jobId: r.jobId ?? null, - locationName: r.locationName ?? null, - departmentName: r.departmentName ?? null, - employmentType: r.employmentType ?? null, - descriptionPlain: r.descriptionPlain ?? r.description ?? null, - isListed: r.isListed ?? false, - publishedDate: r.publishedDate ?? null, - externalLink: r.externalLink ?? null, + id: (r.id as string) ?? '', + title: (r.title as string) ?? '', + descriptionPlain: (r.descriptionPlain as string) ?? null, + descriptionHtml: (r.descriptionHtml as string) ?? null, + descriptionSocial: (r.descriptionSocial as string) ?? null, + descriptionParts: descParts + ? { + descriptionOpening: mapDescriptionPart(descParts.descriptionOpening), + descriptionBody: mapDescriptionPart(descParts.descriptionBody), + descriptionClosing: mapDescriptionPart(descParts.descriptionClosing), + } + : null, + departmentName: (r.departmentName as string) ?? null, + teamName: (r.teamName as string) ?? null, + teamNameHierarchy: Array.isArray(r.teamNameHierarchy) + ? (r.teamNameHierarchy as string[]) + : [], + jobId: (r.jobId as string) ?? null, + locationName: (r.locationName as string) ?? null, + locationIds: r.locationIds + ? { + primaryLocationId: r.locationIds.primaryLocationId ?? null, + secondaryLocationIds: Array.isArray(r.locationIds.secondaryLocationIds) + ? r.locationIds.secondaryLocationIds + : [], + } + : null, + address: r.address + ? { + postalAddress: pa + ? { + addressCountry: (pa.addressCountry as string) ?? null, + addressRegion: (pa.addressRegion as string) ?? null, + addressLocality: (pa.addressLocality as string) ?? null, + postalCode: (pa.postalCode as string) ?? null, + streetAddress: (pa.streetAddress as string) ?? null, + } + : null, + } + : null, + isRemote: (r.isRemote as boolean) ?? false, + workplaceType: (r.workplaceType as string) ?? null, + employmentType: (r.employmentType as string) ?? null, + isListed: (r.isListed as boolean) ?? false, + suppressDescriptionOpening: (r.suppressDescriptionOpening as boolean) ?? false, + suppressDescriptionClosing: (r.suppressDescriptionClosing as boolean) ?? false, + publishedDate: (r.publishedDate as string) ?? null, + applicationDeadline: (r.applicationDeadline as string) ?? null, + externalLink: (r.externalLink as string) ?? null, + applyLink: (r.applyLink as string) ?? null, + compensation: comp + ? { + compensationTierSummary: (comp.compensationTierSummary as string) ?? null, + summaryComponents: summaryComponents.map((c) => ({ + summary: (c.summary as string) ?? null, + compensationTypeLabel: (c.compensationTypeLabel as string) ?? null, + interval: (c.interval as string) ?? null, + currencyCode: (c.currencyCode as string) ?? null, + minValue: (c.minValue as number) ?? null, + maxValue: (c.maxValue as number) ?? null, + })), + shouldDisplayCompensationOnJobBoard: + (comp.shouldDisplayCompensationOnJobBoard as boolean) ?? false, + } + : null, + applicationLimitCalloutHtml: (r.applicationLimitCalloutHtml as string) ?? null, + updatedAt: (r.updatedAt as string) ?? null, }, } }, @@ -82,25 +232,188 @@ export const getJobPostingTool: ToolConfig