@andypai/agent-kanban 0.6.3 → 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 +81 -81
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +44 -56
  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 +15 -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
@@ -47,6 +47,8 @@ import type {
47
47
  UpdateTaskInput,
48
48
  } from './types'
49
49
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
50
+ import { warnOnce } from './warn-once'
51
+ import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
50
52
 
51
53
  export const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
52
54
 
@@ -157,6 +159,36 @@ export interface JiraCachePort {
157
159
  resolveIssueId(lookup: string): Promise<string | null>
158
160
  }
159
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
+
160
192
  /**
161
193
  * Shared Jira provider business logic (sync orchestration, hydration, writes,
162
194
  * transitions, comments, webhook dispatch, activity mapping). Concrete providers
@@ -167,9 +199,7 @@ export class JiraProviderCore implements KanbanProvider {
167
199
  readonly type = 'jira' as const
168
200
  protected readonly client: JiraClient
169
201
  protected readonly pollingSyncIntervalMs: number
170
- // When a server-side background warmer owns cache refresh, request-path syncs
171
- // are suppressed once the cache is warm so reads/writes never block on Jira I/O.
172
- private backgroundManaged = false
202
+ private readonly syncGate: SyncGate
173
203
 
174
204
  constructor(
175
205
  protected readonly cache: JiraCachePort,
@@ -177,6 +207,7 @@ export class JiraProviderCore implements KanbanProvider {
177
207
  client?: JiraClient,
178
208
  ) {
179
209
  this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
210
+ this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
180
211
  this.client =
181
212
  client ??
182
213
  new JiraClient({
@@ -193,7 +224,6 @@ export class JiraProviderCore implements KanbanProvider {
193
224
  protected async sync(force = false, viaWarmer = false): Promise<void> {
194
225
  await this.cache.ready
195
226
  const meta = await this.cache.loadSyncMeta()
196
- const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
197
227
  // Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
198
228
  // Once the cache has synced at least once (lastSyncAt persisted), implicit
199
229
  // request-path reads serve the warm cache instead of blocking on a Jira
@@ -201,9 +231,8 @@ export class JiraProviderCore implements KanbanProvider {
201
231
  // (~minutes) and would otherwise exceed the HTTP idle timeout. Forced syncs
202
232
  // (writes' read-after-write) and the warmer still run; CLI mode and cold start
203
233
  // (no prior sync) fall through and sync synchronously, preserving freshness.
204
- if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
234
+ if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
205
235
  const now = Date.now()
206
- if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
207
236
  // `force` only bypasses the poll throttle; it must NOT imply a full
208
237
  // 1970-based reconcile, which re-fetches every issue plus a per-issue
209
238
  // changelog call (~minutes). Writes get read-after-write freshness from
@@ -215,10 +244,16 @@ export class JiraProviderCore implements KanbanProvider {
215
244
  await this.cache.saveTeamInfo({ id: project.id, key: project.key, name: project.name })
216
245
 
217
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>()
218
252
  if (this.config.boardId !== undefined) {
219
253
  const boardCfg = await this.client.getBoardColumns(this.config.boardId)
220
254
  const boardId = this.config.boardId
221
255
  const rows = jiraBoardColumnRows(boardId, boardCfg.columnConfig.columns)
256
+ for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
222
257
  await this.cache.replaceColumns(rows, fullReconcile)
223
258
  } else {
224
259
  const statusCats = await this.client.getProjectStatuses(project.key)
@@ -238,8 +273,11 @@ export class JiraProviderCore implements KanbanProvider {
238
273
  statusIds: [s.id],
239
274
  source: 'status' as const,
240
275
  }))
276
+ for (const row of rows) for (const id of row.statusIds) mappedStatusIds.add(id)
241
277
  await this.cache.replaceColumns(rows, fullReconcile)
242
278
  }
279
+ // Issue statuses seen this sync that map to no column (statusId → name).
280
+ const unmappedStatuses = new Map<string, string>()
243
281
 
244
282
  // 3. Catalogs: users + priorities + issue types in parallel.
245
283
  // NOTE: listAssignableUsers is capped at 100 in T04; tenants with more
@@ -316,25 +354,7 @@ export class JiraProviderCore implements KanbanProvider {
316
354
  })
317
355
 
318
356
  await this.cache.upsertIssues(
319
- page.issues.map((issue) => ({
320
- id: issue.id,
321
- key: issue.key,
322
- summary: issue.fields.summary,
323
- descriptionText: issue.fields.description
324
- ? adfToPlainText(issue.fields.description as AdfDocument)
325
- : '',
326
- statusId: issue.fields.status.id,
327
- priorityName: issue.fields.priority?.name ?? null,
328
- issueTypeName: issue.fields.issuetype?.name ?? '',
329
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
330
- assigneeName: issue.fields.assignee?.displayName ?? null,
331
- labels: issue.fields.labels ?? [],
332
- commentCount: issue.fields.comment?.total ?? 0,
333
- projectKey: issue.fields.project?.key ?? project.key,
334
- url: `${this.config.baseUrl}/browse/${issue.key}`,
335
- createdAt: issue.fields.created,
336
- updatedAt: issue.fields.updated,
337
- })),
357
+ page.issues.map((issue) => toCacheIssue(issue, this.config.baseUrl, project.key)),
338
358
  )
339
359
 
340
360
  for (const issue of page.issues) {
@@ -342,19 +362,22 @@ export class JiraProviderCore implements KanbanProvider {
342
362
  if (newestUpdatedAt === null || issue.fields.updated > newestUpdatedAt) {
343
363
  newestUpdatedAt = issue.fields.updated
344
364
  }
365
+ if (!mappedStatusIds.has(issue.fields.status.id)) {
366
+ unmappedStatuses.set(issue.fields.status.id, issue.fields.status.name)
367
+ }
345
368
  }
346
369
 
347
370
  // Fetch changelog per changed issue so the poll-based
348
371
  // `moved` trigger in @garage/dispatch works. Server-side dedupe
349
372
  // keyed on (issue_id, history_id, item_field) keeps this cheap
350
373
  // even if the same issue is updated repeatedly.
351
- for (const issue of page.issues) {
374
+ await forEachWithConcurrency(page.issues, 5, async (issue) => {
352
375
  await this.ingestIssueActivity(issue.id).catch((err) => {
353
376
  // Activity is best-effort; the main sync shouldn't fail if
354
377
  // one changelog call 404s or rate-limits.
355
378
  console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
356
379
  })
357
- }
380
+ })
358
381
 
359
382
  const decision = decideJiraPagination(page, seenPageTokens)
360
383
  if (decision.nextToken !== undefined) {
@@ -374,6 +397,15 @@ export class JiraProviderCore implements KanbanProvider {
374
397
  break
375
398
  }
376
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
+
377
409
  if (!paginationComplete) {
378
410
  // The scan ended early (stalled/contradictory cursor). Leave sync metadata
379
411
  // unchanged so this partial result is not recorded as a clean sync: lastSyncAt
@@ -408,6 +440,18 @@ export class JiraProviderCore implements KanbanProvider {
408
440
  return resolveJiraColumnId(await this.cache.getColumns(), input)
409
441
  }
410
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
+
411
455
  private async buildBoardConfig(): Promise<BoardConfig> {
412
456
  const cache = await this.cache.getCachedConfig()
413
457
  const members = cache.users.map((u) => ({
@@ -433,16 +477,12 @@ export class JiraProviderCore implements KanbanProvider {
433
477
  }
434
478
 
435
479
  setBackgroundManaged(managed: boolean): void {
436
- this.backgroundManaged = managed
480
+ this.syncGate.setBackgroundManaged(managed)
437
481
  }
438
482
 
439
483
  async getSyncStatus(): Promise<ProviderSyncStatus> {
440
484
  const meta = await this.cache.loadSyncMeta()
441
- return {
442
- lastSyncAt: meta.lastSyncAt,
443
- lastFullSyncAt: meta.lastFullSyncAt,
444
- lastWebhookAt: meta.lastWebhookAt,
445
- }
485
+ return syncStatusFromMeta(meta)
446
486
  }
447
487
 
448
488
  async getContext(): Promise<ProviderContext> {
@@ -487,15 +527,10 @@ export class JiraProviderCore implements KanbanProvider {
487
527
  async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
488
528
  await this.sync()
489
529
  const columnId = filters.column ? await this.resolveColumnId(filters.column) : undefined
490
- let tasks = await this.cache.getCachedTasks(columnId ? { columnId } : undefined)
491
- if (filters.priority) tasks = tasks.filter((t) => t.priority === filters.priority)
492
- if (filters.assignee) tasks = tasks.filter((t) => t.assignee === filters.assignee)
493
- if (filters.project) tasks = tasks.filter((t) => t.project === filters.project)
494
- if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
495
- if (filters.sort === 'updated')
496
- tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
497
- if (filters.limit) tasks = tasks.slice(0, filters.limit)
498
- return tasks
530
+ return applyTaskFilters(
531
+ await this.cache.getCachedTasks(columnId ? { columnId } : undefined),
532
+ filters,
533
+ )
499
534
  }
500
535
 
501
536
  async getTask(idOrRef: string): Promise<Task> {
@@ -587,25 +622,7 @@ export class JiraProviderCore implements KanbanProvider {
587
622
  // it rather than threading an unreachable null through every caller.
588
623
  const issue = await this.client.getIssue(key)
589
624
  await this.cache.upsertIssues([
590
- {
591
- id: issue.id,
592
- key: issue.key,
593
- summary: issue.fields.summary,
594
- descriptionText: issue.fields.description
595
- ? adfToPlainText(issue.fields.description as AdfDocument)
596
- : '',
597
- statusId: issue.fields.status.id,
598
- priorityName: issue.fields.priority?.name ?? null,
599
- issueTypeName: issue.fields.issuetype?.name ?? '',
600
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
601
- assigneeName: issue.fields.assignee?.displayName ?? null,
602
- labels: issue.fields.labels ?? [],
603
- commentCount: issue.fields.comment?.total ?? 0,
604
- projectKey: issue.fields.project?.key ?? this.config.projectKey,
605
- url: `${this.config.baseUrl}/browse/${issue.key}`,
606
- createdAt: issue.fields.created,
607
- updatedAt: issue.fields.updated,
608
- },
625
+ toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
609
626
  ])
610
627
  // Ingest the changelog like the sync loop does, so a just-applied transition
611
628
  // is recorded in jira_activity immediately (backs getActivity and the
@@ -856,7 +873,8 @@ export class JiraProviderCore implements KanbanProvider {
856
873
  // SQLite calls it directly via the default handleWebhook above.
857
874
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
858
875
  if (!process.env['JIRA_WEBHOOK_SECRET']) {
859
- console.warn(
876
+ warnOnce(
877
+ 'jira-webhook-open-dev-mode',
860
878
  '[jira] JIRA_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
861
879
  )
862
880
  }
@@ -892,25 +910,7 @@ export class JiraProviderCore implements KanbanProvider {
892
910
  }
893
911
  }
894
912
  await this.cache.upsertIssues([
895
- {
896
- id: issue.id,
897
- key: issue.key,
898
- summary: issue.fields.summary,
899
- descriptionText: issue.fields.description
900
- ? adfToPlainText(issue.fields.description as AdfDocument)
901
- : '',
902
- statusId: issue.fields.status.id,
903
- priorityName: issue.fields.priority?.name ?? null,
904
- issueTypeName: issue.fields.issuetype?.name ?? '',
905
- assigneeAccountId: issue.fields.assignee?.accountId ?? null,
906
- assigneeName: issue.fields.assignee?.displayName ?? null,
907
- labels: issue.fields.labels ?? [],
908
- commentCount: issue.fields.comment?.total ?? 0,
909
- projectKey,
910
- url: `${this.config.baseUrl}/browse/${issue.key}`,
911
- createdAt: issue.fields.created,
912
- updatedAt: issue.fields.updated,
913
- },
913
+ toCacheIssue(issue, this.config.baseUrl, this.config.projectKey),
914
914
  ])
915
915
  if (event === 'jira:issue_updated') {
916
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,
@@ -35,22 +40,17 @@ import type {
35
40
  UpdateTaskInput,
36
41
  } from './types'
37
42
  import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
43
+ import { warnOnce } from './warn-once'
44
+ import {
45
+ applyTaskFilters,
46
+ maxSyncTimestamp,
47
+ parseSyncTimestamp,
48
+ SyncGate,
49
+ syncStatusFromMeta,
50
+ } from './sync-core'
38
51
 
39
52
  const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
40
53
 
41
- function parseTimestamp(value: string | null | undefined): number {
42
- if (!value) return 0
43
- const parsed = Date.parse(value)
44
- return Number.isFinite(parsed) ? parsed : 0
45
- }
46
-
47
- function maxTimestamp(a: string | null | undefined, b: string | null | undefined): string | null {
48
- const aMs = parseTimestamp(a)
49
- const bMs = parseTimestamp(b)
50
- if (!aMs && !bMs) return null
51
- return aMs >= bMs ? (a ?? null) : (b ?? null)
52
- }
53
-
54
54
  function toLinearPriority(priority: Task['priority'] | undefined): number | undefined {
55
55
  switch (priority) {
56
56
  case 'urgent':
@@ -168,16 +168,16 @@ export interface LinearCachePort {
168
168
  */
169
169
  export class LinearProviderCore implements KanbanProvider {
170
170
  readonly type = 'linear' as const
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 Linear I/O.
173
- private backgroundManaged = false
171
+ private readonly syncGate: SyncGate
174
172
 
175
173
  constructor(
176
174
  protected readonly cache: LinearCachePort,
177
175
  protected readonly teamId: string,
178
176
  protected readonly client: LinearClient,
179
177
  protected readonly pollingSyncIntervalMs = DEFAULT_POLLING_SYNC_INTERVAL_MS,
180
- ) {}
178
+ ) {
179
+ this.syncGate = new SyncGate(this.pollingSyncIntervalMs)
180
+ }
181
181
 
182
182
  async initialize(): Promise<void> {
183
183
  await this.cache.ready
@@ -200,16 +200,14 @@ export class LinearProviderCore implements KanbanProvider {
200
200
  protected async sync(force = false, viaWarmer = false): Promise<void> {
201
201
  await this.cache.ready
202
202
  const meta = await this.cache.loadSyncMeta()
203
- const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
204
- const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
203
+ const lastFullSyncAtMs = parseSyncTimestamp(meta.lastFullSyncAt)
205
204
  // Server mode: a background warmer (syncCache(), viaWarmer=true) owns refresh.
206
205
  // Once the cache has synced at least once, implicit request-path reads serve the
207
206
  // warm cache instead of blocking on a Linear round-trip, which could exceed the
208
207
  // HTTP idle timeout. Forced syncs (writes' read-after-write) and the warmer
209
208
  // still run; CLI mode and cold start sync synchronously.
210
- if (this.backgroundManaged && !force && !viaWarmer && lastSyncAtMs) return
209
+ if (this.syncGate.shouldSkip({ force, viaWarmer, lastSyncAt: meta.lastSyncAt })) return
211
210
  const now = Date.now()
212
- if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
213
211
 
214
212
  // `force` bypasses the poll throttle (above) for read-after-write freshness,
215
213
  // but does NOT imply a full workspace reconcile — that stays on its own
@@ -237,7 +235,7 @@ export class LinearProviderCore implements KanbanProvider {
237
235
  await this.cache.pruneIssues(issues.map((issue) => issue.id))
238
236
  }
239
237
 
240
- const newestIssueTimestamp = maxTimestamp(
238
+ const newestIssueTimestamp = maxSyncTimestamp(
241
239
  meta.lastIssueUpdatedAt,
242
240
  issues.length > 0
243
241
  ? issues.reduce(
@@ -267,6 +265,9 @@ export class LinearProviderCore implements KanbanProvider {
267
265
  private async ingestTeamHistory(issueIds: string[], sinceIso: string | null): Promise<void> {
268
266
  if (issueIds.length === 0) return
269
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.
270
271
  for (let i = 0; i < issueIds.length; i += concurrency) {
271
272
  const batch = issueIds.slice(i, i + concurrency)
272
273
  const results = await Promise.all(
@@ -318,6 +319,10 @@ export class LinearProviderCore implements KanbanProvider {
318
319
  return task
319
320
  }
320
321
 
322
+ private issueIdFor(task: Task): string {
323
+ return task.providerId || task.id.replace(/^linear:/, '')
324
+ }
325
+
321
326
  // Read-after-write refresh of a single issue. Re-fetching just the mutated
322
327
  // issue (and its history) keeps the post-write read fresh without paying for a
323
328
  // full team sync(true)/prune on every update or move.
@@ -344,17 +349,7 @@ export class LinearProviderCore implements KanbanProvider {
344
349
  }
345
350
 
346
351
  private async resolveState(column: string): Promise<Column> {
347
- const states = await this.cache.getCachedColumns()
348
- const match = states.find(
349
- (state) => state.id === column || state.name.toLowerCase() === column.toLowerCase(),
350
- )
351
- if (!match) {
352
- throw new KanbanError(
353
- ErrorCode.COLUMN_NOT_FOUND,
354
- `No Linear workflow state matching '${column}'`,
355
- )
356
- }
357
- return match
352
+ return resolveLinearState(await this.cache.getCachedColumns(), column)
358
353
  }
359
354
 
360
355
  // Only invoked for non-empty names. A name that the cache cannot resolve is a
@@ -396,16 +391,12 @@ export class LinearProviderCore implements KanbanProvider {
396
391
  }
397
392
 
398
393
  setBackgroundManaged(managed: boolean): void {
399
- this.backgroundManaged = managed
394
+ this.syncGate.setBackgroundManaged(managed)
400
395
  }
401
396
 
402
397
  async getSyncStatus(): Promise<ProviderSyncStatus> {
403
398
  const meta = await this.cache.loadSyncMeta()
404
- return {
405
- lastSyncAt: meta.lastSyncAt,
406
- lastFullSyncAt: meta.lastFullSyncAt,
407
- lastWebhookAt: meta.lastWebhookAt,
408
- }
399
+ return syncStatusFromMeta(meta)
409
400
  }
410
401
 
411
402
  async getContext(): Promise<ProviderContext> {
@@ -448,14 +439,7 @@ export class LinearProviderCore implements KanbanProvider {
448
439
  const column = await this.resolveState(filters.column)
449
440
  tasks = tasks.filter((task) => task.column_id === column.id)
450
441
  }
451
- if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
452
- if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
453
- if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
454
- if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
455
- if (filters.sort === 'updated')
456
- tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
457
- if (filters.limit) tasks = tasks.slice(0, filters.limit)
458
- return tasks
442
+ return applyTaskFilters(tasks, filters)
459
443
  }
460
444
 
461
445
  async getTask(idOrRef: string): Promise<Task> {
@@ -507,22 +491,24 @@ export class LinearProviderCore implements KanbanProvider {
507
491
  if (input.metadata !== undefined) {
508
492
  unsupportedOperation('Linear mode does not support metadata updates')
509
493
  }
510
- 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)
511
496
  if (!result.success) {
512
497
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue update failed')
513
498
  }
514
- return this.hydrateIssue(task.providerId || task.id)
499
+ return this.hydrateIssue(issueId)
515
500
  }
516
501
 
517
502
  async moveTask(idOrRef: string, column: string): Promise<Task> {
518
503
  await this.sync()
519
504
  const task = await this.resolveTask(idOrRef)
520
505
  const state = await this.resolveState(column)
521
- 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 })
522
508
  if (!result.success) {
523
509
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear issue move failed')
524
510
  }
525
- return this.hydrateIssue(task.providerId || task.id)
511
+ return this.hydrateIssue(issueId)
526
512
  }
527
513
 
528
514
  async deleteTask(_idOrRef: string): Promise<Task> {
@@ -532,7 +518,7 @@ export class LinearProviderCore implements KanbanProvider {
532
518
  async listComments(idOrRef: string): Promise<TaskComment[]> {
533
519
  await this.sync()
534
520
  const task = await this.resolveTask(idOrRef)
535
- const comments = await this.client.listComments(task.providerId || task.id)
521
+ const comments = await this.client.listComments(this.issueIdFor(task))
536
522
  return comments.map((comment) => this.toTaskComment(task, comment))
537
523
  }
538
524
 
@@ -546,11 +532,12 @@ export class LinearProviderCore implements KanbanProvider {
546
532
  async comment(idOrRef: string, body: string): Promise<TaskComment> {
547
533
  await this.sync()
548
534
  const task = await this.resolveTask(idOrRef)
549
- 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)
550
537
  if (!result.success || !result.comment) {
551
538
  throw new KanbanError(ErrorCode.PROVIDER_UPSTREAM_ERROR, 'Linear comment creation failed')
552
539
  }
553
- await this.cache.adjustIssueCommentCount(task.providerId || task.id, 1)
540
+ await this.cache.adjustIssueCommentCount(issueId, 1)
554
541
  return this.toTaskComment(task, result.comment)
555
542
  }
556
543
 
@@ -610,7 +597,8 @@ export class LinearProviderCore implements KanbanProvider {
610
597
  // SQLite calls it directly via the default handleWebhook above.
611
598
  protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
612
599
  if (!process.env['LINEAR_WEBHOOK_SECRET']) {
613
- console.warn(
600
+ warnOnce(
601
+ 'linear-webhook-open-dev-mode',
614
602
  '[linear] LINEAR_WEBHOOK_SECRET is not set — accepting webhook without signature verification (open dev mode)',
615
603
  )
616
604
  }