@andypai/agent-kanban 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +78 -80
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +41 -55
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +6 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. package/ui/dist/assets/index-Cigv8a9S.js +0 -40
@@ -48,6 +48,7 @@ import type {
48
48
  } from './types'
49
49
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
50
50
  import { warnOnce } from './warn-once'
51
+ import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
51
52
 
52
53
  export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
53
54
 
@@ -158,6 +159,36 @@ export interface JiraCachePort {
158
159
  resolveIssueId(lookup: string): Promise<string | null>
159
160
  }
160
161
 
162
+ type JiraCacheIssue = Parameters<JiraCachePort['upsertIssues']>[0][number]
163
+
164
+ // Map a Jira API issue to the cache upsert row. Shared by bulk sync, direct
165
+ // hydrate, and webhook ingest so every path caches the same issue shape.
166
+ function toCacheIssue(
167
+ issue: JiraIssue,
168
+ baseUrl: string,
169
+ fallbackProjectKey: string,
170
+ ): JiraCacheIssue {
171
+ return {
172
+ id: issue.id,
173
+ key: issue.key,
174
+ summary: issue.fields.summary,
175
+ descriptionText: issue.fields.description
176
+ ? adfToPlainText(issue.fields.description as AdfDocument)
177
+ : '',
178
+ statusId: issue.fields.status.id,
179
+ priorityName: issue.fields.priority?.name ?? null,
180
+ issueTypeName: issue.fields.issuetype?.name ?? '',
181
+ assigneeAccountId: issue.fields.assignee?.accountId ?? null,
182
+ assigneeName: issue.fields.assignee?.displayName ?? null,
183
+ labels: issue.fields.labels ?? [],
184
+ commentCount: issue.fields.comment?.total ?? 0,
185
+ projectKey: issue.fields.project?.key ?? fallbackProjectKey,
186
+ url: `${baseUrl}/browse/${issue.key}`,
187
+ createdAt: issue.fields.created,
188
+ updatedAt: issue.fields.updated,
189
+ }
190
+ }
191
+
161
192
  /**
162
193
  * Shared Jira provider business logic (sync orchestration, hydration, writes,
163
194
  * transitions, comments, webhook dispatch, activity mapping). Concrete providers
@@ -168,9 +199,7 @@ export class JiraProviderCore implements KanbanProvider {
168
199
  readonly type = 'jira' as const
169
200
  protected readonly client: JiraClient
170
201
  protected readonly pollingSyncIntervalMs: number
171
- // When a server-side background warmer owns cache refresh, request-path syncs
172
- // are suppressed once the cache is warm so reads/writes never block on Jira I/O.
173
- private backgroundManaged = false
202
+ private readonly syncGate: SyncGate
174
203
 
175
204
  constructor(
176
205
  protected readonly cache: JiraCachePort,
@@ -178,6 +207,7 @@ export class JiraProviderCore implements KanbanProvider {
178
207
  client?: JiraClient,
179
208
  ) {
180
209
  this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
210
+ this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
181
211
  this.client =
182
212
  client ??
183
213
  new JiraClient({
@@ -194,7 +224,6 @@ export class JiraProviderCore implements KanbanProvider {
194
224
  protected async sync(force = false, viaWarmer = false): Promise<void> {
195
225
  await this.cache.ready
196
226
  const meta = await this.cache.loadSyncMeta()
197
- const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
198
227
  // Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
199
228
  // Once the cache has synced at least once (lastSyncAt persisted), implicit
200
229
  // request-path reads serve the warm cache instead of blocking on a Jira
@@ -202,9 +231,8 @@ export class JiraProviderCore implements KanbanProvider {
202
231
  // (~minutes) and would otherwise exceed the HTTP idle timeout. Forced syncs
203
232
  // (writes' read-after-write) and the warmer still run; CLI mode and cold start
204
233
  // (no prior sync) fall through and sync synchronously, preserving freshness.
205
- if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
234
+ if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
206
235
  const now = Date.now()
207
- if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
208
236
  // `force` only bypasses the poll throttle; it must NOT imply a full
209
237
  // 1970-based reconcile, which re-fetches every issue plus a per-issue
210
238
  // changelog call (~minutes). Writes get read-after-write freshness from
@@ -216,10 +244,16 @@ export class JiraProviderCore implements KanbanProvider {
216
244
  await this.cache.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
217
245
 
218
246
  // 2. Columns: board path OR status fallback path.
247
+ // The projection buckets issues by status-id membership in a column's
248
+ // status_ids; a status id present on an issue but in no column is cached but
249
+ // invisible to every listTasks({column}). Track the mapped set so we can
250
+ // surface such unmapped statuses below instead of dropping them silently.
251
+ const mappedStatusIds = new Set<string>()
219
252
  if (this.config.boardId !== undefined) {
220
253
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
221
254
  const boardId = this.config.boardId
222
255
  const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
256
+ for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
223
257
  await this.cache.replaceColumns(rows, fullReconcile)
224
258
  } else {
225
259
  const statusCats = await this.client.getProjectStatuses(project.key)
@@ -239,8 +273,11 @@ export class JiraProviderCore implements KanbanProvider {
239
273
  statusIds: [s.id],
240
274
  source: 'status' as const,
241
275
  }))
276
+ for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
242
277
  await this.cache.replaceColumns(rows, fullReconcile)
243
278
  }
279
+ // Issue statuses seen this sync that map to no column (statusId → name).
280
+ const unmappedStatuses = new Map<string, string>()
244
281
 
245
282
  // 3. Catalogs: users + priorities + issue types in parallel.
246
283
  // NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
@@ -317,25 +354,7 @@ export class JiraProviderCore implements KanbanProvider {
317
354
  })
318
355
 
319
356
  await this.cache.upsertIssues(
320
- page.issues.map((issue) => ({
321
- id: issue.id,
322
- key: issue.key,
323
- summary: issue.fields.summary,
324
- descriptionText: issue.fields.description
325
- ? adfToPlainText(issue.fields.description as AdfDocument)
326
- : '',
327
- statusId: issue.fields.status.id,
328
- priorityName: issue.fields.priority?.name ?? null,
329
- issueTypeName: issue.fields.issuetype?.name ?? '',
330
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
331
- assigneeName: issue.fields.assignee?.displayName ?? null,
332
- labels: issue.fields.labels ?? [],
333
- commentCount: issue.fields.comment?.total ?? 0,
334
- projectKey: issue.fields.project?.key ?? project.key,
335
- url: `${this.config.baseUrl}/browse/${issue.key}`,
336
- createdAt: issue.fields.created,
337
- updatedAt: issue.fields.updated,
338
- })),
357
+ page.issues.map((issue) => toCacheIssue(issue, this.config.baseUrl, project.key)),
339
358
  )
340
359
 
341
360
  for (const issue of page.issues) {
@@ -343,19 +362,22 @@ export class JiraProviderCore implements KanbanProvider {
343
362
  if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
344
363
  newestUpdatedAt = issue.fields.updated
345
364
  }
365
+ if (!mappedStatusIds.has(issue.fields.status.id)) {
366
+ unmappedStatuses.set(issue.fields.status.id, issue.fields.status.name)
367
+ }
346
368
  }
347
369
 
348
370
  // Fetch changelog per changed issue so the poll-based
349
371
  // `moved` trigger in @garage/dispatch works. Server-side dedupe
350
372
  // keyed on (issue_id, history_id, item_field) keeps this cheap
351
373
  // even if the same issue is updated repeatedly.
352
- for (const issue of page.issues) {
374
+ await forEachWithConcurrency(page.issues, 5, async (issue) => {
353
375
  await this.ingestIssueActivity(issue.id).catch((err) => {
354
376
  // Activity is best-effort; the main sync shouldn't fail if
355
377
  // one changelog call 404s or rate-limits.
356
378
  console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
357
379
  })
358
- }
380
+ })
359
381
 
360
382
  const decision = decideJiraPagination(page, seenPageTokens)
361
383
  if (decision.nextToken !== undefined) {
@@ -375,6 +397,15 @@ export class JiraProviderCore implements KanbanProvider {
375
397
  break
376
398
  }
377
399
 
400
+ // Surface issues whose status maps to no column. They are cached but invisible
401
+ // to every listTasks({column}) and getCachedBoard — a silent drop that strands
402
+ // tickets (e.g. a poller's trigger column never sees them). Warn once per
403
+ // (project, status) so a misconfigured board or a status missing from the
404
+ // project's status catalog is observable rather than silent.
405
+ for (const [statusId, statusName] of unmappedStatuses) {
406
+ this.warnUnmappedStatus(project.key, statusId, statusName)
407
+ }
408
+
378
409
  if (!paginationComplete) {
379
410
  // The scan ended early (stalled/contradictory cursor). Leave sync metadata
380
411
  // unchanged so this partial result is not recorded as a clean sync: lastSyncAt
@@ -409,6 +440,18 @@ export class JiraProviderCore implements KanbanProvider {
409
440
  return resolveJiraColumnId(await this.cache.getColumns(), input)
410
441
  }
411
442
 
443
+ private warnUnmappedStatus(projectKey: string, statusId: string, statusName: string): void {
444
+ const hint =
445
+ this.config.boardId !== undefined
446
+ ? `it is not on board ${this.config.boardId}`
447
+ : `it is absent from the project status catalog`
448
+ warnOnce(
449
+ `jira-unmapped-status:${projectKey}:${statusId}`,
450
+ `[jira] ${projectKey} status '${statusName}' (${statusId}) maps to no column (${hint}); ` +
451
+ `issues in this status are cached but invisible to listTasks/getBoard`,
452
+ )
453
+ }
454
+
412
455
  private async buildBoardConfig(): Promise<BoardConfig> {
413
456
  const cache = await this.cache.getCachedConfig()
414
457
  const members = cache.users.map((u) => ({
@@ -434,16 +477,12 @@ export class JiraProviderCore implements KanbanProvider {
434
477
  }
435
478
 
436
479
  setBackgroundManaged(managed: boolean): void {
437
- this.backgroundManaged = managed
480
+ this.syncGate.setBackgroundManaged(managed)
438
481
  }
439
482
 
440
483
  async getSyncStatus(): Promise<ProviderSyncStatus> {
441
484
  const meta = await this.cache.loadSyncMeta()
442
- return {
443
- lastSyncAt: meta.lastSyncAt,
444
- lastFullSyncAt: meta.lastFullSyncAt,
445
- lastWebhookAt: meta.lastWebhookAt,
446
- }
485
+ return syncStatusFromMeta(meta)
447
486
  }
448
487
 
449
488
  async getContext(): Promise<ProviderContext> {
@@ -488,15 +527,10 @@ export class JiraProviderCore implements KanbanProvider {
488
527
  async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
489
528
  await this.sync()
490
529
  const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
491
- let tasks = await this.cache.getCachedTasks(columnId ? { columnId } : undefined)
492
- if (filters.priority) tasks = tasks.filter((t) => t.priority === filters.priority)
493
- if (filters.assignee) tasks = tasks.filter((t) => t.assignee === filters.assignee)
494
- if (filters.project) tasks = tasks.filter((t) => t.project === filters.project)
495
- if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
496
- if (filters.sort === 'updated')
497
- tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
498
- if (filters.limit) tasks = tasks.slice(0, filters.limit)
499
- return tasks
530
+ return applyTaskFilters(
531
+ await this.cache.getCachedTasks(columnId ? { columnId } : undefined),
532
+ filters,
533
+ )
500
534
  }
501
535
 
502
536
  async getTask(idOrRef: string): Promise<Task> {
@@ -588,25 +622,7 @@ export class JiraProviderCore implements KanbanProvider {
588
622
  // it rather than threading an unreachable null through every caller.
589
623
  const issue = await this.client.getIssue(key)
590
624
  await this.cache.upsertIssues([
591
- {
592
- id: issue.id,
593
- key: issue.key,
594
- summary: issue.fields.summary,
595
- descriptionText: issue.fields.description
596
- ? adfToPlainText(issue.fields.description as AdfDocument)
597
- : '',
598
- statusId: issue.fields.status.id,
599
- priorityName: issue.fields.priority?.name ?? null,
600
- issueTypeName: issue.fields.issuetype?.name ?? '',
601
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
602
- assigneeName: issue.fields.assignee?.displayName ?? null,
603
- labels: issue.fields.labels ?? [],
604
- commentCount: issue.fields.comment?.total ?? 0,
605
- projectKey: issue.fields.project?.key ?? this.config.projectKey,
606
- url: `${this.config.baseUrl}/browse/${issue.key}`,
607
- createdAt: issue.fields.created,
608
- updatedAt: issue.fields.updated,
609
- },
625
+ toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
610
626
  ])
611
627
  // Ingest the changelog like the sync loop does, so a just-applied transition
612
628
  // is recorded in jira_activity immediately (backs getActivity and the
@@ -894,25 +910,7 @@ export class JiraProviderCore implements KanbanProvider {
894
910
  }
895
911
  }
896
912
  await this.cache.upsertIssues([
897
- {
898
- id: issue.id,
899
- key: issue.key,
900
- summary: issue.fields.summary,
901
- descriptionText: issue.fields.description
902
- ? adfToPlainText(issue.fields.description as AdfDocument)
903
- : '',
904
- statusId: issue.fields.status.id,
905
- priorityName: issue.fields.priority?.name ?? null,
906
- issueTypeName: issue.fields.issuetype?.name ?? '',
907
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
908
- assigneeName: issue.fields.assignee?.displayName ?? null,
909
- labels: issue.fields.labels ?? [],
910
- commentCount: issue.fields.comment?.total ?? 0,
911
- projectKey,
912
- url: `${this.config.baseUrl}/browse/${issue.key}`,
913
- createdAt: issue.fields.created,
914
- updatedAt: issue.fields.updated,
915
- },
913
+ toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
916
914
  ])
917
915
  if (event === 'jira:issue_updated') {
918
916
  await this.ingestIssueActivity(issue.id).catch((err) => {
@@ -1,5 +1,9 @@
1
1
  import type { Database } from 'bun:sqlite'
2
+ import { normalizeColumnName } from '../column-roles'
3
+ import { ErrorCode, KanbanError } from '../errors'
2
4
  import type { BoardConfig, BoardView, ProviderTeamInfo, Task } from '../types'
5
+ import { linearTaskFromRow, type LinearTaskRow } from './cache-task-mappers'
6
+ import { parseProviderTeamInfo } from './team-info'
3
7
 
4
8
  export interface LinearStateRow {
5
9
  id: string
@@ -19,6 +23,39 @@ export interface LinearSyncMeta {
19
23
  lastWebhookAt: string | null
20
24
  }
21
25
 
26
+ function ambiguousStateError(input: string, matches: LinearStateRow[]): KanbanError {
27
+ return new KanbanError(
28
+ ErrorCode.COLUMN_NOT_FOUND,
29
+ `Linear state name '${input}' is ambiguous; use one of these state ids: ${matches
30
+ .map((state) => state.id)
31
+ .join(', ')}`,
32
+ )
33
+ }
34
+
35
+ // Resolves a user-supplied column reference to a cached workflow state, trying
36
+ // (1) exact id, (2) case-insensitive name, (3) separator-insensitive name
37
+ // ('in-progress' → 'In Progress') — mirroring resolveJiraColumnId so collapsed
38
+ // trigger strings resolve on both providers instead of throwing
39
+ // COLUMN_NOT_FOUND. Exact passes run first so a precise reference is never
40
+ // overridden by a normalized-name collision; name lookups matching multiple
41
+ // states are rejected as ambiguous so the caller picks an id. A token that
42
+ // collapses to empty is skipped so punctuation-only input never matches.
43
+ export function resolveLinearState(states: LinearStateRow[], input: string): LinearStateRow {
44
+ const byId = states.find((state) => state.id === input)
45
+ if (byId) return byId
46
+ const lower = input.toLowerCase()
47
+ const byName = states.filter((state) => state.name.toLowerCase() === lower)
48
+ if (byName.length === 1) return byName[0]!
49
+ if (byName.length > 1) throw ambiguousStateError(input, byName)
50
+ const normalized = normalizeColumnName(input)
51
+ if (normalized !== '') {
52
+ const byNormalized = states.filter((state) => normalizeColumnName(state.name) === normalized)
53
+ if (byNormalized.length === 1) return byNormalized[0]!
54
+ if (byNormalized.length > 1) throw ambiguousStateError(input, byNormalized)
55
+ }
56
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No Linear workflow state matching '${input}'`)
57
+ }
58
+
22
59
  export function initLinearCacheSchema(db: Database): void {
23
60
  db.run(`
24
61
  CREATE TABLE IF NOT EXISTS linear_sync_meta (
@@ -200,9 +237,8 @@ export function saveSyncMeta(db: Database, meta: Partial<LinearSyncMeta>): void
200
237
  }
201
238
 
202
239
  export function loadSyncMeta(db: Database): LinearSyncMeta {
203
- const teamRaw = getMeta(db, 'team')
204
240
  return {
205
- team: teamRaw ? (JSON.parse(teamRaw) as ProviderTeamInfo) : null,
241
+ team: parseProviderTeamInfo(getMeta(db, 'team')),
206
242
  lastSyncAt: getMeta(db, 'lastSyncAt'),
207
243
  lastFullSyncAt: getMeta(db, 'lastFullSyncAt'),
208
244
  lastIssueUpdatedAt: getMeta(db, 'lastIssueUpdatedAt'),
@@ -442,70 +478,7 @@ export function getCachedColumns(db: Database): LinearStateRow[] {
442
478
  return db.query('SELECT * FROM linear_states ORDER BY position, name').all() as LinearStateRow[]
443
479
  }
444
480
 
445
- function mapPriority(priority: number): Task['priority'] {
446
- switch (priority) {
447
- case 1:
448
- return 'urgent'
449
- case 2:
450
- return 'high'
451
- case 3:
452
- return 'medium'
453
- case 0:
454
- case 4:
455
- default:
456
- return 'low'
457
- }
458
- }
459
-
460
- interface LinearIssueRow {
461
- id: string
462
- identifier: string
463
- title: string
464
- description: string
465
- state_id: string
466
- state_position: number
467
- priority: number
468
- assignee_name: string
469
- project_name: string
470
- labels: string
471
- comment_count: number
472
- url: string | null
473
- created_at: string
474
- updated_at: string
475
- }
476
-
477
- function parseLabels(raw: string): string[] {
478
- try {
479
- const parsed: unknown = JSON.parse(raw)
480
- return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
481
- } catch {
482
- return []
483
- }
484
- }
485
-
486
- function taskFromRow(row: LinearIssueRow): Task {
487
- return {
488
- id: `linear:${row.id}`,
489
- providerId: row.id,
490
- externalRef: row.identifier,
491
- url: row.url,
492
- title: row.title,
493
- description: row.description,
494
- column_id: row.state_id,
495
- position: row.state_position,
496
- priority: mapPriority(row.priority),
497
- assignee: row.assignee_name,
498
- assignees: row.assignee_name ? [row.assignee_name] : [],
499
- labels: parseLabels(row.labels),
500
- comment_count: row.comment_count,
501
- project: row.project_name,
502
- metadata: '{}',
503
- created_at: row.created_at,
504
- updated_at: row.updated_at,
505
- version: row.updated_at,
506
- source_updated_at: row.updated_at,
507
- }
508
- }
481
+ type LinearIssueRow = LinearTaskRow
509
482
 
510
483
  export function getCachedBoard(db: Database): BoardView {
511
484
  const columns = getCachedColumns(db)
@@ -520,7 +493,7 @@ export function getCachedBoard(db: Database): BoardView {
520
493
  ORDER BY updated_at DESC, title ASC`,
521
494
  )
522
495
  .all({ $state_id: column.id }) as LinearIssueRow[]
523
- ).map(taskFromRow),
496
+ ).map(linearTaskFromRow),
524
497
  })),
525
498
  }
526
499
  }
@@ -534,7 +507,7 @@ export function getCachedTask(db: Database, lookup: string): Task | null {
534
507
  LIMIT 1`,
535
508
  )
536
509
  .get({ $lookup: normalized }) as LinearIssueRow | null
537
- return row ? taskFromRow(row) : null
510
+ return row ? linearTaskFromRow(row) : null
538
511
  }
539
512
 
540
513
  export function getCachedTasks(db: Database): Task[] {
@@ -542,7 +515,7 @@ export function getCachedTasks(db: Database): Task[] {
542
515
  db
543
516
  .query('SELECT * FROM linear_issues ORDER BY updated_at DESC, title ASC')
544
517
  .all() as LinearIssueRow[]
545
- ).map(taskFromRow)
518
+ ).map(linearTaskFromRow)
546
519
  }
547
520
 
548
521
  export function getCachedConfig(db: Database): BoardConfig {
@@ -24,7 +24,12 @@ import {
24
24
  type LinearComment,
25
25
  type LinearIssue,
26
26
  } from './linear-client'
27
- import type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
27
+ import {
28
+ resolveLinearState,
29
+ type LinearActivityRow,
30
+ type LinearStateRow,
31
+ type LinearSyncMeta,
32
+ } from './linear-cache'
28
33
  import { providerUpstreamError, unsupportedOperation } from './errors'
29
34
  import type {
30
35
  CreateTaskInput,
@@ -36,22 +41,16 @@ import type {
36
41
  } from './types'
37
42
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
38
43
  import { warnOnce } from './warn-once'
44
+ import {
45
+ applyTaskFilters,
46
+ maxSyncTimestamp,
47
+ parseSyncTimestamp,
48
+ SyncGate,
49
+ syncStatusFromMeta,
50
+ } from './sync-core'
39
51
 
40
52
  const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
41
53
 
42
- function parseTimestamp(value: string | null | undefined): number {
43
- if (!value) return 0
44
- const parsed = Date.parse(value)
45
- return Number.isFinite(parsed) ? parsed : 0
46
- }
47
-
48
- function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
49
- const aMs = parseTimestamp(a)
50
- const bMs = parseTimestamp(b)
51
- if (!aMs && !bMs) return null
52
- return aMs >= bMs ? (a ?? null) : (b ?? null)
53
- }
54
-
55
54
  function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
56
55
  switch (priority) {
57
56
  case 'urgent':
@@ -169,16 +168,16 @@ export interface LinearCachePort {
169
168
  */
170
169
  export class LinearProviderCore implements KanbanProvider {
171
170
  readonly type = 'linear' as const
172
- // When a server-side background warmer owns cache refresh, request-path syncs
173
- // are suppressed once the cache is warm so reads/writes never block on Linear I/O.
174
- private backgroundManaged = false
171
+ private readonly syncGate: SyncGate
175
172
 
176
173
  constructor(
177
174
  protected readonly cache: LinearCachePort,
178
175
  protected readonly teamId: string,
179
176
  protected readonly client: LinearClient,
180
177
  protected readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
181
- ) {}
178
+ ) {
179
+ this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
180
+ }
182
181
 
183
182
  async initialize(): Promise<void> {
184
183
  await this.cache.ready
@@ -201,16 +200,14 @@ export class LinearProviderCore implements KanbanProvider {
201
200
  protected async sync(force = false, viaWarmer = false): Promise<void> {
202
201
  await this.cache.ready
203
202
  const meta = await this.cache.loadSyncMeta()
204
- const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
205
- const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
203
+ const lastFullSyncAtMs = parseSyncTimestamp(meta.lastFullSyncAt)
206
204
  // Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
207
205
  // Once the cache has synced at least once, implicit request-path reads serve the
208
206
  // warm cache instead of blocking on a Linear round-trip, which could exceed the
209
207
  // HTTP idle timeout. Forced syncs (writes' read-after-write) and the warmer
210
208
  // still run; CLI mode and cold start sync synchronously.
211
- if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
209
+ if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
212
210
  const now = Date.now()
213
- if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
214
211
 
215
212
  // `force` bypasses the poll throttle (above) for read-after-write freshness,
216
213
  // but does NOT imply a full workspace reconcile — that stays on its own
@@ -238,7 +235,7 @@ export class LinearProviderCore implements KanbanProvider {
238
235
  await this.cache.pruneIssues(issues.map((issue) => issue.id))
239
236
  }
240
237
 
241
- const newestIssueTimestamp = maxTimestamp(
238
+ const newestIssueTimestamp = maxSyncTimestamp(
242
239
  meta.lastIssueUpdatedAt,
243
240
  issues.length > 0
244
241
  ? issues.reduce(
@@ -268,6 +265,9 @@ export class LinearProviderCore implements KanbanProvider {
268
265
  private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
269
266
  if (issueIds.length === 0) return
270
267
  const concurrency = 5
268
+ // Manual windows rather than one mapWithConcurrency pass: each window's rows
269
+ // are persisted before the next window starts, so a mid-ingest failure keeps
270
+ // everything fetched so far.
271
271
  for (let i = 0; i < issueIds.length; i += concurrency) {
272
272
  const batch = issueIds.slice(i, i + concurrency)
273
273
  const results = await Promise.all(
@@ -319,6 +319,10 @@ export class LinearProviderCore implements KanbanProvider {
319
319
  return task
320
320
  }
321
321
 
322
+ private issueIdFor(task: Task): string {
323
+ return task.providerId || task.id.replace(/^linear:/, '')
324
+ }
325
+
322
326
  // Read-after-write refresh of a single issue. Re-fetching just the mutated
323
327
  // issue (and its history) keeps the post-write read fresh without paying for a
324
328
  // full team sync(true)/prune on every update or move.
@@ -345,17 +349,7 @@ export class LinearProviderCore implements KanbanProvider {
345
349
  }
346
350
 
347
351
  private async resolveState(column: string): Promise<Column> {
348
- const states = await this.cache.getCachedColumns()
349
- const match = states.find(
350
- (state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
351
- )
352
- if (!match) {
353
- throw new KanbanError(
354
- ErrorCode.COLUMN_NOT_FOUND,
355
- `No Linear workflow state matching '${column}'`,
356
- )
357
- }
358
- return match
352
+ return resolveLinearState(await this.cache.getCachedColumns(), column)
359
353
  }
360
354
 
361
355
  // Only invoked for non-empty names. A name that the cache cannot resolve is a
@@ -397,16 +391,12 @@ export class LinearProviderCore implements KanbanProvider {
397
391
  }
398
392
 
399
393
  setBackgroundManaged(managed: boolean): void {
400
- this.backgroundManaged = managed
394
+ this.syncGate.setBackgroundManaged(managed)
401
395
  }
402
396
 
403
397
  async getSyncStatus(): Promise<ProviderSyncStatus> {
404
398
  const meta = await this.cache.loadSyncMeta()
405
- return {
406
- lastSyncAt: meta.lastSyncAt,
407
- lastFullSyncAt: meta.lastFullSyncAt,
408
- lastWebhookAt: meta.lastWebhookAt,
409
- }
399
+ return syncStatusFromMeta(meta)
410
400
  }
411
401
 
412
402
  async getContext(): Promise<ProviderContext> {
@@ -449,14 +439,7 @@ export class LinearProviderCore implements KanbanProvider {
449
439
  const column = await this.resolveState(filters.column)
450
440
  tasks = tasks.filter((task) => task.column_id === column.id)
451
441
  }
452
- if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
453
- if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
454
- if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
455
- if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
456
- if (filters.sort === 'updated')
457
- tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
458
- if (filters.limit) tasks = tasks.slice(0, filters.limit)
459
- return tasks
442
+ return applyTaskFilters(tasks, filters)
460
443
  }
461
444
 
462
445
  async getTask(idOrRef: string): Promise<Task> {
@@ -508,22 +491,24 @@ export class LinearProviderCore implements KanbanProvider {
508
491
  if (input.metadata !== undefined) {
509
492
  unsupportedOperation('Linear mode does not support metadata updates')
510
493
  }
511
- const result = await this.client.updateIssue(task.providerId || task.id, updateInput)
494
+ const issueId = this.issueIdFor(task)
495
+ const result = await this.client.updateIssue(issueId, updateInput)
512
496
  if (!result.success) {
513
497
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
514
498
  }
515
- return this.hydrateIssue(task.providerId || task.id)
499
+ return this.hydrateIssue(issueId)
516
500
  }
517
501
 
518
502
  async moveTask(idOrRef: string, column: string): Promise<Task> {
519
503
  await this.sync()
520
504
  const task = await this.resolveTask(idOrRef)
521
505
  const state = await this.resolveState(column)
522
- const result = await this.client.updateIssue(task.providerId || task.id, { stateId: state.id })
506
+ const issueId = this.issueIdFor(task)
507
+ const result = await this.client.updateIssue(issueId, { stateId: state.id })
523
508
  if (!result.success) {
524
509
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
525
510
  }
526
- return this.hydrateIssue(task.providerId || task.id)
511
+ return this.hydrateIssue(issueId)
527
512
  }
528
513
 
529
514
  async deleteTask(_idOrRef: string): Promise<Task> {
@@ -533,7 +518,7 @@ export class LinearProviderCore implements KanbanProvider {
533
518
  async listComments(idOrRef: string): Promise<TaskComment[]> {
534
519
  await this.sync()
535
520
  const task = await this.resolveTask(idOrRef)
536
- const comments = await this.client.listComments(task.providerId || task.id)
521
+ const comments = await this.client.listComments(this.issueIdFor(task))
537
522
  return comments.map((comment) => this.toTaskComment(task, comment))
538
523
  }
539
524
 
@@ -547,11 +532,12 @@ export class LinearProviderCore implements KanbanProvider {
547
532
  async comment(idOrRef: string, body: string): Promise<TaskComment> {
548
533
  await this.sync()
549
534
  const task = await this.resolveTask(idOrRef)
550
- const result = await this.client.commentCreate(task.providerId || task.id, body)
535
+ const issueId = this.issueIdFor(task)
536
+ const result = await this.client.commentCreate(issueId, body)
551
537
  if (!result.success || !result.comment) {
552
538
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
553
539
  }
554
- await this.cache.adjustIssueCommentCount(task.providerId || task.id, 1)
540
+ await this.cache.adjustIssueCommentCount(issueId, 1)
555
541
  return this.toTaskComment(task, result.comment)
556
542
  }
557
543