Skip to content

Commit 1f4c2fb

Browse files
committed
fix(connectors): purge archived/deleted source items across seven more KB connectors
The sync engine only purges a knowledge-base document when its source item is absent from a full-sync listing, so any connector that keeps listing archived/trashed/canceled items never drops them. An audit of all 51 connectors found seven with this bug: - asana: list only non-archived projects (the API returns both when `archived` is omitted), so tasks under archived projects stop being re-listed - google-sheets: skip a spreadsheet Drive reports as trashed, which stays readable by id for 30 days before the Sheets call starts 404ing - incidentio: exclude canceled incidents by default (cancelling is incident.io's documented stand-in for deletion), with an explicit opt-in to sync them - outlook: exclude Deleted Items from the all-mail listing, which Graph otherwise includes - servicenow: drop retired knowledge articles, which the Table API returns with no implicit state filter - webflow: drop archived CMS items, which the staged items endpoint always returns and offers no way to filter - youtube: drop playlist entries whose video was deleted or made private, which the API keeps returning as placeholder items Every exclusion keys off an explicit non-current signal and fails open on a missing field or a failed metadata read, since wrongly excluding a live item would hard-delete it. Explicit user filter selections are still honoured verbatim; the new defaults apply only when nothing is configured. Also flag truncated listings as capped in asana, outlook, and servicenow. All three silently cut a listing short at their configured item cap without setting `syncContext.listingCapped`, so reconciliation read the untraversed tail as deleted at the source and hard-deleted it.
1 parent 6f4ee47 commit 1f4c2fb

15 files changed

Lines changed: 3153 additions & 79 deletions

File tree

apps/sim/connectors/asana/asana.test.ts

Lines changed: 442 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/connectors/asana/asana.ts

Lines changed: 144 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interface AsanaPageResponse {
2323
/**
2424
* Minimal Asana task shape used by this connector.
2525
*/
26-
interface AsanaTask {
26+
export interface AsanaTask {
2727
gid: string
2828
name: string
2929
notes?: string
@@ -33,6 +33,7 @@ interface AsanaTask {
3333
assignee?: { name: string }
3434
tags?: { name: string }[]
3535
permalink_url?: string
36+
projects?: AsanaProject[]
3637
}
3738

3839
/**
@@ -46,9 +47,125 @@ interface AsanaWorkspace {
4647
/**
4748
* Asana project shape.
4849
*/
49-
interface AsanaProject {
50+
export interface AsanaProject {
5051
gid: string
5152
name: string
53+
archived?: boolean
54+
}
55+
56+
/**
57+
* Optional fields requested when enumerating workspace projects. `archived` is
58+
* not returned by default and is needed to defensively re-check the server-side
59+
* `archived=false` filter.
60+
*/
61+
const PROJECT_OPT_FIELDS = 'gid,name,archived'
62+
63+
/**
64+
* Optional fields requested when re-fetching a single task. Adds the task's
65+
* parent projects (with their archived flag) on top of the listing fields so
66+
* `isTaskUnderActiveProject` can run on the rehydrate path. `opt_fields`
67+
* supports dot paths, so `projects.archived` expands the compact project stubs
68+
* with the field the listing filter relies on.
69+
*/
70+
const TASK_DETAIL_OPT_FIELDS = `${TASK_OPT_FIELDS},projects.archived`
71+
72+
/**
73+
* Builds the workspace projects listing path.
74+
*
75+
* `GET /projects` only filters on `archived` when the parameter is supplied —
76+
* omitting it returns archived AND active projects. Archived projects are
77+
* hidden from view in Asana, yet their tasks keep appearing in the connector's
78+
* full listing, so those tasks would never fall out via deletion reconciliation
79+
* (which hard-deletes only stored documents absent from the full listing) and
80+
* would linger in the knowledge base forever.
81+
*
82+
* `opt_fields` is appended raw rather than through `URLSearchParams` so its
83+
* separators stay literal commas, matching every other task call in this
84+
* connector and Asana's own documented examples. Only the caller-supplied
85+
* values (workspace gid, pagination offset) go through `URLSearchParams`, which
86+
* is where escaping actually matters.
87+
*/
88+
export function buildProjectsPath(workspaceGid: string, offset?: string): string {
89+
const params = new URLSearchParams({
90+
workspace: workspaceGid,
91+
archived: 'false',
92+
limit: '100',
93+
})
94+
if (offset) params.append('offset', offset)
95+
return `/projects?${params.toString()}&opt_fields=${PROJECT_OPT_FIELDS}`
96+
}
97+
98+
/**
99+
* Keeps only projects that are still active. Asana regressed the server-side
100+
* `archived=false` filter once before (fixed 2024-11-06), so results are
101+
* re-checked client side. Fails open: a project is dropped only on an explicit
102+
* `archived === true`, never on a missing or non-boolean field.
103+
*/
104+
export function isActiveProject(project: AsanaProject): boolean {
105+
return project.archived !== true
106+
}
107+
108+
/**
109+
* Mirrors `isActiveProject` on the single-task rehydrate path so a task whose
110+
* every parent project is archived cannot be resurrected after the listing
111+
* dropped it. Fails open in every ambiguous case: a task with no `projects`
112+
* field (the field is optional and only returned when requested), an empty
113+
* array, or any entry whose `archived` is missing or non-boolean is kept. A
114+
* task that still sits in at least one active project is kept, matching the
115+
* listing, which reaches it through that active project.
116+
*/
117+
export function isTaskUnderActiveProject(task: AsanaTask): boolean {
118+
const projects = task.projects
119+
if (!Array.isArray(projects) || projects.length === 0) return true
120+
return !projects.every((project) => project?.archived === true)
121+
}
122+
123+
/**
124+
* Outcome of applying the `maxTasks` cap to one listing page.
125+
*/
126+
export interface TaskCapDecision {
127+
/** How many of this page's documents to keep. */
128+
keepCount: number
129+
/** True once the cap is reached, so pagination must stop. */
130+
hitLimit: boolean
131+
/**
132+
* True when the cap made the listing knowingly incomplete — either documents
133+
* on this page were dropped, or pages beyond the cap were left unread. The
134+
* caller flags `syncContext.listingCapped` so the sync engine refuses to
135+
* reconcile deletions against a partial listing and hard-delete everything
136+
* past the cap.
137+
*/
138+
truncated: boolean
139+
}
140+
141+
/**
142+
* Decides how the `maxTasks` cap applies to a listing page.
143+
*
144+
* `pageDocumentCount` and `previouslyFetched` are pre-filter counts of returned
145+
* tasks — the cap is never derived from post-filter array lengths. Reaching the
146+
* cap exactly as the source runs out (`morePagesAvailable === false` and nothing
147+
* dropped) leaves the listing complete, so it is not reported as truncated.
148+
*/
149+
export function decideTaskCap(
150+
maxTasks: number,
151+
previouslyFetched: number,
152+
pageDocumentCount: number,
153+
morePagesAvailable: boolean
154+
): TaskCapDecision {
155+
if (!(maxTasks > 0)) {
156+
return { keepCount: pageDocumentCount, hitLimit: false, truncated: false }
157+
}
158+
159+
const remaining = Math.max(maxTasks - previouslyFetched, 0)
160+
const keepCount = Math.min(pageDocumentCount, remaining)
161+
const droppedFromPage = keepCount < pageDocumentCount
162+
const hitLimit = previouslyFetched + keepCount >= maxTasks
163+
164+
return {
165+
keepCount,
166+
hitLimit,
167+
truncated: droppedFromPage || (hitLimit && morePagesAvailable),
168+
}
52169
}
53170

54171
/**
@@ -106,7 +223,18 @@ function buildTaskContent(task: AsanaTask): string {
106223
}
107224

108225
/**
109-
* Fetches all project GIDs in a workspace, used when no specific project is configured.
226+
* Fetches all active project GIDs in a workspace, used when no specific project
227+
* is configured. Archived projects are excluded so their tasks drop out of the
228+
* full listing and get purged by deletion reconciliation. A project the user
229+
* pinned explicitly via the `project` config field keeps syncing even once
230+
* archived — that is a deliberate user choice, not a stale listing.
231+
*
232+
* This is a one-time destructive change on rollout: the first full sync after
233+
* deploy stops listing tasks that live only under archived projects, so
234+
* deletion reconciliation removes their stored documents. That is the intended
235+
* correction — those documents track content the workspace already archived —
236+
* but it must be called out in the release notes, since re-indexing them
237+
* requires unarchiving the project in Asana.
110238
*/
111239
async function listWorkspaceProjects(
112240
accessToken: string,
@@ -117,12 +245,11 @@ async function listWorkspaceProjects(
117245

118246
// eslint-disable-next-line no-constant-condition
119247
while (true) {
120-
const offsetParam = offset ? `&offset=${offset}` : ''
121248
const result = await asanaGet<{ data: AsanaProject[]; next_page: { offset: string } | null }>(
122249
accessToken,
123-
`/projects?workspace=${workspaceGid}&limit=100${offsetParam}`
250+
buildProjectsPath(workspaceGid, offset)
124251
)
125-
projects.push(...result.data)
252+
projects.push(...result.data.filter(isActiveProject))
126253
if (!result.next_page) break
127254
offset = result.next_page.offset
128255
}
@@ -235,18 +362,13 @@ export const asanaConnector: ConnectorConfig = {
235362
}
236363

237364
const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0
238-
if (maxTasks > 0) {
239-
const remaining = maxTasks - previouslyFetched
240-
if (documents.length > remaining) {
241-
documents.splice(remaining)
242-
}
243-
}
365+
const cap = decideTaskCap(maxTasks, previouslyFetched, documents.length, hasMore)
366+
if (cap.keepCount < documents.length) documents.splice(cap.keepCount)
244367

245-
const totalFetched = previouslyFetched + documents.length
246-
if (syncContext) syncContext.totalDocsFetched = totalFetched
247-
const hitLimit = maxTasks > 0 && totalFetched >= maxTasks
368+
if (syncContext) syncContext.totalDocsFetched = previouslyFetched + documents.length
369+
if (cap.truncated && syncContext) syncContext.listingCapped = true
248370

249-
if (hitLimit) {
371+
if (cap.hitLimit) {
250372
hasMore = false
251373
nextCursor = undefined
252374
}
@@ -266,12 +388,17 @@ export const asanaConnector: ConnectorConfig = {
266388
try {
267389
const result = await asanaGet<{ data: AsanaTask }>(
268390
accessToken,
269-
`/tasks/${externalId}?opt_fields=${TASK_OPT_FIELDS}`
391+
`/tasks/${externalId}?opt_fields=${TASK_DETAIL_OPT_FIELDS}`
270392
)
271393
const task = result.data
272394

273395
if (!task) return null
274396

397+
if (!isTaskUnderActiveProject(task)) {
398+
logger.info('Skipping Asana task whose projects are all archived', { externalId })
399+
return null
400+
}
401+
275402
const content = buildTaskContent(task)
276403
const tagNames = task.tags?.map((t) => t.name).filter(Boolean) || []
277404

0 commit comments

Comments
 (0)