@@ -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 */
111239async 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