@andypai/agent-kanban 0.3.3 → 0.3.4

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.
package/README.md CHANGED
@@ -62,18 +62,19 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
62
62
 
63
63
  All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
64
64
 
65
- | Variable | Default | Description |
66
- | ------------------ | ------------- | ------------------------------------------------------------------------ |
67
- | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
- | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
- | `LINEAR_API_KEY` | | Required when `KANBAN_PROVIDER=linear` |
70
- | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
71
- | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
72
- | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
73
- | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
74
- | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
75
- | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
76
- | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
65
+ | Variable | Default | Description |
66
+ | ------------------------- | ------------- | ------------------------------------------------------------------------ |
67
+ | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
+ | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
69
+ | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
70
+ | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
71
+ | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
72
+ | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
73
+ | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
74
+ | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
75
+ | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
76
+ | `JIRA_BOARD_ID` | | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
77
+ | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
77
78
 
78
79
  Without `KANBAN_DB_PATH`, the local provider resolves the database in this order:
79
80
 
@@ -301,8 +302,9 @@ Starts a Bun HTTP server with:
301
302
  - **Sync status** at `/api/sync-status` — reports background sync state plus provider sync metadata
302
303
 
303
304
  In `serve` mode, remote providers now warm once on startup and continue syncing
304
- in the background every 30 seconds. Full reconciliation is still handled by the
305
- provider-specific logic on top of that steady cadence.
305
+ in the background every `KANBAN_SYNC_INTERVAL_MS` milliseconds. Full
306
+ reconciliation is still handled by the provider-specific logic on top of that
307
+ steady cadence.
306
308
 
307
309
  Comment routes:
308
310
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
5
5
  "homepage": "https://github.com/abpai/agent-kanban#readme",
6
6
  "repository": {
@@ -240,6 +240,28 @@ describe('JiraProvider read path', () => {
240
240
  }
241
241
  })
242
242
 
243
+ test('custom polling sync interval refreshes before the default 30 seconds', async () => {
244
+ const { fn, calls } = jiraFetchStub(standardRoutes({}))
245
+ globalThis.fetch = fn
246
+ const client = new JiraClient({
247
+ baseUrl: baseConfig.baseUrl,
248
+ email: baseConfig.email,
249
+ apiToken: baseConfig.apiToken,
250
+ })
251
+ const provider = new JiraProvider(db, { ...baseConfig, pollingSyncIntervalMs: 5_000 }, client)
252
+ saveJiraSyncMeta(db, {
253
+ projectKey: 'ENG',
254
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
255
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
256
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
257
+ })
258
+ Date.now = () => Date.parse('2026-01-01T00:00:06.000Z')
259
+
260
+ await provider.listTasks()
261
+
262
+ expect(calls.some((call) => call.url.includes('/rest/api/3/search/jql'))).toBe(true)
263
+ })
264
+
243
265
  test('sync delta JQL is exactly project = KEY AND updated >= "<ts>" ORDER BY updated ASC', async () => {
244
266
  const capturedJql: string[] = []
245
267
  // First sync: one issue returned so lastIssueUpdatedAt is set.
@@ -485,4 +485,81 @@ describe('LinearProvider sync', () => {
485
485
 
486
486
  expect(issueQueries).toBe(1)
487
487
  })
488
+
489
+ test('custom polling sync interval refreshes before the default 30 seconds', async () => {
490
+ let issueQueries = 0
491
+
492
+ saveSyncMeta(db, {
493
+ team: { id: 'team-1', key: 'R2P', name: 'R2pi' },
494
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
495
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
496
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
497
+ })
498
+
499
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
500
+ const body = JSON.parse(String(init?.body)) as {
501
+ query: string
502
+ variables: Record<string, unknown>
503
+ }
504
+
505
+ if (body.query.includes('query TeamSnapshot')) {
506
+ return new Response(
507
+ JSON.stringify({
508
+ data: {
509
+ team: {
510
+ id: 'team-1',
511
+ key: 'R2P',
512
+ name: 'R2pi',
513
+ states: { nodes: [{ id: 'state-1', name: 'Todo', position: 0 }] },
514
+ },
515
+ },
516
+ }),
517
+ { status: 200, headers: { 'content-type': 'application/json' } },
518
+ )
519
+ }
520
+
521
+ if (body.query.includes('query Users')) {
522
+ return new Response(JSON.stringify({ data: { users: { nodes: [] } } }), {
523
+ status: 200,
524
+ headers: { 'content-type': 'application/json' },
525
+ })
526
+ }
527
+
528
+ if (body.query.includes('query Projects')) {
529
+ return new Response(JSON.stringify({ data: { projects: { nodes: [] } } }), {
530
+ status: 200,
531
+ headers: { 'content-type': 'application/json' },
532
+ })
533
+ }
534
+
535
+ if (body.query.includes('query Issues')) {
536
+ issueQueries += 1
537
+ return new Response(
538
+ JSON.stringify({
539
+ data: {
540
+ issues: {
541
+ nodes: [],
542
+ pageInfo: { hasNextPage: false, endCursor: null },
543
+ },
544
+ },
545
+ }),
546
+ { status: 200, headers: { 'content-type': 'application/json' } },
547
+ )
548
+ }
549
+
550
+ return new Response(`Unexpected query: ${body.query}`, { status: 500 })
551
+ }) as unknown as typeof fetch
552
+
553
+ const originalDateNow = Date.now
554
+ Date.now = () => Date.parse('2026-01-01T00:00:06.000Z')
555
+
556
+ try {
557
+ const provider = new LinearProvider(db, 'R2P', 'lin_api_test', 5_000)
558
+ await provider.getBoard()
559
+ } finally {
560
+ Date.now = originalDateNow
561
+ }
562
+
563
+ expect(issueQueries).toBe(1)
564
+ })
488
565
  })
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ErrorCode, KanbanError } from '../errors'
3
+ import {
4
+ DEFAULT_POLLING_SYNC_INTERVAL_MS,
5
+ MIN_POLLING_SYNC_INTERVAL_MS,
6
+ resolvePollingSyncIntervalMs,
7
+ } from '../sync-config'
8
+
9
+ describe('sync config', () => {
10
+ test('defaults when unset or blank', () => {
11
+ expect(resolvePollingSyncIntervalMs(undefined)).toBe(DEFAULT_POLLING_SYNC_INTERVAL_MS)
12
+ expect(resolvePollingSyncIntervalMs(' ')).toBe(DEFAULT_POLLING_SYNC_INTERVAL_MS)
13
+ })
14
+
15
+ test('accepts an integer millisecond interval', () => {
16
+ expect(resolvePollingSyncIntervalMs('5000')).toBe(5_000)
17
+ })
18
+
19
+ test('rejects invalid or too-aggressive intervals', () => {
20
+ for (const raw of ['999', '5s', '1000.5', '0']) {
21
+ let err: unknown
22
+ try {
23
+ resolvePollingSyncIntervalMs(raw)
24
+ } catch (caught) {
25
+ err = caught
26
+ }
27
+ expect(err).toBeInstanceOf(KanbanError)
28
+ expect((err as KanbanError).code).toBe(ErrorCode.INVALID_CONFIG)
29
+ expect((err as Error).message).toContain(String(MIN_POLLING_SYNC_INTERVAL_MS))
30
+ }
31
+ })
32
+ })
package/src/errors.ts CHANGED
@@ -9,6 +9,7 @@ export const ErrorCode = {
9
9
  INVALID_PRIORITY: 'INVALID_PRIORITY',
10
10
  INVALID_METADATA: 'INVALID_METADATA',
11
11
  INVALID_POSITION: 'INVALID_POSITION',
12
+ INVALID_CONFIG: 'INVALID_CONFIG',
12
13
  CONFLICT: 'CONFLICT',
13
14
  MISSING_ARGUMENT: 'MISSING_ARGUMENT',
14
15
  UNKNOWN_COMMAND: 'UNKNOWN_COMMAND',
package/src/mcp/errors.ts CHANGED
@@ -39,6 +39,7 @@ function providerError(code: ErrorCodeValue): TrackerMcpErrorCode {
39
39
  case ErrorCode.INVALID_METADATA:
40
40
  case ErrorCode.INVALID_POSITION:
41
41
  case ErrorCode.INVALID_PRIORITY:
42
+ case ErrorCode.INVALID_CONFIG:
42
43
  case ErrorCode.MISSING_ARGUMENT:
43
44
  case ErrorCode.UNSUPPORTED_OPERATION:
44
45
  case ErrorCode.CONFLICT:
@@ -5,6 +5,7 @@ import { JiraProvider } from './jira'
5
5
  import { LinearProvider } from './linear'
6
6
  import { LocalProvider } from './local'
7
7
  import type { KanbanProvider } from './types'
8
+ import { resolvePollingSyncIntervalMs } from '../sync-config'
8
9
 
9
10
  export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvider {
10
11
  const providerType = (process.env['KANBAN_PROVIDER'] ?? 'local') as 'local' | 'linear' | 'jira'
@@ -17,7 +18,7 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
17
18
  'LINEAR_API_KEY and LINEAR_TEAM_ID are required when KANBAN_PROVIDER=linear',
18
19
  )
19
20
  }
20
- return new LinearProvider(db, teamId!, apiKey!)
21
+ return new LinearProvider(db, teamId!, apiKey!, resolvePollingSyncIntervalMs())
21
22
  }
22
23
 
23
24
  if (providerType === 'jira') {
@@ -45,6 +46,7 @@ export function createProvider(db: Database, dbPath = getDbPath()): KanbanProvid
45
46
  projectKey: projectKey!,
46
47
  boardId: Number.isFinite(boardId) ? boardId : undefined,
47
48
  defaultIssueType,
49
+ pollingSyncIntervalMs: resolvePollingSyncIntervalMs(),
48
50
  })
49
51
  }
50
52
 
@@ -49,8 +49,8 @@ import type {
49
49
  TaskListFilters,
50
50
  UpdateTaskInput,
51
51
  } from './types'
52
+ import { resolvePollingSyncIntervalMs } from '../sync-config'
52
53
 
53
- const SYNC_INTERVAL_MS = 30_000
54
54
  const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
55
55
 
56
56
  function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
@@ -78,11 +78,13 @@ export interface JiraProviderConfig {
78
78
  projectKey: string
79
79
  boardId?: number
80
80
  defaultIssueType?: string
81
+ pollingSyncIntervalMs?: number
81
82
  }
82
83
 
83
84
  export class JiraProvider implements KanbanProvider {
84
85
  readonly type = 'jira' as const
85
86
  private readonly client: JiraClient
87
+ private readonly pollingSyncIntervalMs: number
86
88
 
87
89
  constructor(
88
90
  private readonly db: Database,
@@ -90,6 +92,7 @@ export class JiraProvider implements KanbanProvider {
90
92
  client?: JiraClient,
91
93
  ) {
92
94
  initJiraCacheSchema(db)
95
+ this.pollingSyncIntervalMs = config.pollingSyncIntervalMs ?? resolvePollingSyncIntervalMs()
93
96
  this.client =
94
97
  client ??
95
98
  new JiraClient({
@@ -103,7 +106,7 @@ export class JiraProvider implements KanbanProvider {
103
106
  const meta = loadJiraSyncMeta(this.db)
104
107
  const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
105
108
  const now = Date.now()
106
- if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
107
110
  const fullReconcile = force || shouldRunFullReconcile(meta.lastFullSyncAt, now)
108
111
 
109
112
  // 1. Resolve project.
@@ -41,8 +41,8 @@ import type {
41
41
  TaskListFilters,
42
42
  UpdateTaskInput,
43
43
  } from './types'
44
+ import { resolvePollingSyncIntervalMs } from '../sync-config'
44
45
 
45
- const SYNC_INTERVAL_MS = 30_000
46
46
  const FULL_RECONCILIATION_INTERVAL_MS = 5 * 60_000
47
47
 
48
48
  function parseTimestamp(value: string | null | undefined): number {
@@ -81,6 +81,7 @@ export class LinearProvider implements KanbanProvider {
81
81
  private readonly db: Database,
82
82
  private readonly teamId: string,
83
83
  apiKey: string,
84
+ private readonly pollingSyncIntervalMs = resolvePollingSyncIntervalMs(),
84
85
  ) {
85
86
  initLinearCacheSchema(db)
86
87
  this.client = new LinearClient(apiKey)
@@ -105,7 +106,7 @@ export class LinearProvider implements KanbanProvider {
105
106
  const lastSyncAtMs = parseTimestamp(meta.lastSyncAt)
106
107
  const lastFullSyncAtMs = parseTimestamp(meta.lastFullSyncAt)
107
108
  const now = Date.now()
108
- if (!force && lastSyncAtMs && now - lastSyncAtMs < SYNC_INTERVAL_MS) return
109
+ if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
109
110
 
110
111
  const shouldFullSync =
111
112
  force ||
package/src/server.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
  import { handleRequest } from './api'
4
4
  import type { ServerWebSocket } from 'bun'
5
5
  import type { KanbanProvider } from './providers/types'
6
+ import { resolvePollingSyncIntervalMs } from './sync-config'
6
7
 
7
8
  const wsClients = new Set<ServerWebSocket<unknown>>()
8
9
  const CORS_HEADERS = {
@@ -10,8 +11,6 @@ const CORS_HEADERS = {
10
11
  'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
11
12
  'Access-Control-Allow-Headers': 'Content-Type',
12
13
  }
13
- const DEFAULT_BACKGROUND_SYNC_INTERVAL_MS = 30_000
14
-
15
14
  interface BackgroundSyncState {
16
15
  enabled: boolean
17
16
  inFlight: boolean
@@ -64,7 +63,7 @@ export function startServer(
64
63
  ): StartedServer {
65
64
  const distDir = join(import.meta.dir, '..', 'ui', 'dist')
66
65
  const hasStatic = existsSync(distDir)
67
- const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_BACKGROUND_SYNC_INTERVAL_MS
66
+ const syncIntervalMs = opts.syncIntervalMs ?? resolvePollingSyncIntervalMs()
68
67
  const syncCache = provider.syncCache?.bind(provider)
69
68
  const getSyncStatus = provider.getSyncStatus?.bind(provider)
70
69
  const backgroundSync: BackgroundSyncState = {
@@ -0,0 +1,18 @@
1
+ import { ErrorCode, KanbanError } from './errors'
2
+
3
+ export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
4
+ export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
5
+
6
+ export function resolvePollingSyncIntervalMs(raw = process.env['KANBAN_SYNC_INTERVAL_MS']): number {
7
+ const value = raw?.trim()
8
+ if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
9
+
10
+ const parsed = Number(value)
11
+ if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
12
+ throw new KanbanError(
13
+ ErrorCode.INVALID_CONFIG,
14
+ `KANBAN_SYNC_INTERVAL_MS must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
15
+ )
16
+ }
17
+ return parsed
18
+ }