@andypai/agent-kanban 0.3.4 → 0.3.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.
@@ -0,0 +1,611 @@
1
+ import type { Sql } from 'postgres'
2
+
3
+ import { ErrorCode, KanbanError } from '../errors'
4
+ import { generateId } from '../id'
5
+ import type {
6
+ ActivityEntry,
7
+ BoardBootstrap,
8
+ BoardConfig,
9
+ BoardMetrics,
10
+ BoardView,
11
+ Column,
12
+ Priority,
13
+ Task,
14
+ TaskComment,
15
+ TaskWithColumn,
16
+ } from '../types'
17
+ import { LOCAL_CAPABILITIES } from './capabilities'
18
+ import type {
19
+ CreateTaskInput,
20
+ KanbanProvider,
21
+ ProviderContext,
22
+ ProviderSyncStatus,
23
+ TaskListFilters,
24
+ UpdateTaskInput,
25
+ } from './types'
26
+ import type { LocalTrackerConfig } from '../tracker-config'
27
+
28
+ const DEFAULT_COLUMNS = [
29
+ { name: 'recurring', position: 0 },
30
+ { name: 'backlog', position: 1 },
31
+ { name: 'in-progress', position: 2 },
32
+ { name: 'review', position: 3 },
33
+ { name: 'done', position: 4 },
34
+ ]
35
+
36
+ interface TaskRow {
37
+ id: string
38
+ title: string
39
+ description: string
40
+ column_id: string
41
+ column_name?: string
42
+ position: number
43
+ priority: Priority
44
+ assignee: string
45
+ project: string
46
+ metadata: string
47
+ revision: number
48
+ created_at: string
49
+ updated_at: string
50
+ }
51
+
52
+ interface ActivityRow {
53
+ id: string
54
+ task_id: string
55
+ action: ActivityEntry['action']
56
+ field_changed: string | null
57
+ old_value: string | null
58
+ new_value: string | null
59
+ timestamp: string
60
+ }
61
+
62
+ function nowIso(): string {
63
+ return new Date().toISOString()
64
+ }
65
+
66
+ function defaultColumns(config: Pick<LocalTrackerConfig, 'defaultColumns'>): Array<{
67
+ name: string
68
+ position: number
69
+ }> {
70
+ const names =
71
+ config.defaultColumns && config.defaultColumns.length > 0
72
+ ? config.defaultColumns
73
+ : DEFAULT_COLUMNS.map((column) => column.name)
74
+ return names.map((name, position) => ({ name, position }))
75
+ }
76
+
77
+ function assertPriority(priority: string): asserts priority is Priority {
78
+ if (!['low', 'medium', 'high', 'urgent'].includes(priority)) {
79
+ throw new KanbanError(ErrorCode.INVALID_PRIORITY, `Invalid priority: ${priority}`)
80
+ }
81
+ }
82
+
83
+ function parseMetadata(metadata: string | undefined): string {
84
+ if (metadata === undefined) return '{}'
85
+ try {
86
+ JSON.parse(metadata)
87
+ return metadata
88
+ } catch {
89
+ throw new KanbanError(ErrorCode.INVALID_METADATA, 'Metadata must be valid JSON')
90
+ }
91
+ }
92
+
93
+ export class PostgresLocalProvider implements KanbanProvider {
94
+ readonly type = 'local' as const
95
+ private readonly ready: Promise<void>
96
+
97
+ constructor(
98
+ private readonly sql: Sql,
99
+ private readonly config: Pick<LocalTrackerConfig, 'defaultColumns' | 'defaultTaskColumn'> = {},
100
+ ) {
101
+ this.ready = this.ensureSchema().then(() => this.seedDefaultColumns())
102
+ }
103
+
104
+ async initialize(): Promise<void> {
105
+ await this.ready
106
+ }
107
+
108
+ private async ensureSchema(): Promise<void> {
109
+ await this.sql`
110
+ CREATE TABLE IF NOT EXISTS columns (
111
+ id TEXT PRIMARY KEY,
112
+ name TEXT UNIQUE NOT NULL,
113
+ position INTEGER NOT NULL,
114
+ color TEXT,
115
+ created_at TEXT NOT NULL,
116
+ updated_at TEXT NOT NULL
117
+ )
118
+ `
119
+ await this.sql`
120
+ CREATE TABLE IF NOT EXISTS tasks (
121
+ id TEXT PRIMARY KEY,
122
+ title TEXT NOT NULL,
123
+ description TEXT NOT NULL DEFAULT '',
124
+ column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE RESTRICT,
125
+ position INTEGER NOT NULL DEFAULT 0,
126
+ priority TEXT NOT NULL DEFAULT 'medium',
127
+ assignee TEXT NOT NULL DEFAULT '',
128
+ project TEXT NOT NULL DEFAULT '',
129
+ metadata TEXT NOT NULL DEFAULT '{}',
130
+ revision INTEGER NOT NULL DEFAULT 0,
131
+ created_at TEXT NOT NULL,
132
+ updated_at TEXT NOT NULL
133
+ )
134
+ `
135
+ await this.sql`
136
+ CREATE TABLE IF NOT EXISTS activity_log (
137
+ id TEXT PRIMARY KEY,
138
+ task_id TEXT NOT NULL,
139
+ action TEXT NOT NULL,
140
+ field_changed TEXT,
141
+ old_value TEXT,
142
+ new_value TEXT,
143
+ timestamp TEXT NOT NULL
144
+ )
145
+ `
146
+ await this.sql`
147
+ CREATE TABLE IF NOT EXISTS comments (
148
+ id TEXT PRIMARY KEY,
149
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
150
+ body TEXT NOT NULL,
151
+ author TEXT,
152
+ created_at TEXT NOT NULL,
153
+ updated_at TEXT NOT NULL
154
+ )
155
+ `
156
+ await this.sql`
157
+ CREATE TABLE IF NOT EXISTS column_time_tracking (
158
+ id TEXT PRIMARY KEY,
159
+ task_id TEXT NOT NULL,
160
+ column_id TEXT NOT NULL,
161
+ entered_at TEXT NOT NULL,
162
+ exited_at TEXT
163
+ )
164
+ `
165
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id)`
166
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)`
167
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)`
168
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)`
169
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_activity_task_id ON activity_log(task_id)`
170
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp)`
171
+ await this.sql`CREATE INDEX IF NOT EXISTS idx_comments_task_id ON comments(task_id)`
172
+ await this
173
+ .sql`CREATE INDEX IF NOT EXISTS idx_column_time_task_id ON column_time_tracking(task_id)`
174
+ }
175
+
176
+ private async seedDefaultColumns(): Promise<void> {
177
+ const [row] = await this.sql<
178
+ { count: string | number }[]
179
+ >`SELECT COUNT(*) AS count FROM columns`
180
+ if (Number(row?.count ?? 0) > 0) return
181
+
182
+ for (const column of defaultColumns(this.config)) {
183
+ await this.sql`
184
+ INSERT INTO columns (id, name, position, created_at, updated_at)
185
+ VALUES (${generateId('c')}, ${column.name}, ${column.position}, ${nowIso()}, ${nowIso()})
186
+ `
187
+ }
188
+ }
189
+
190
+ private enrichTask(row: TaskRow, commentCount = 0): TaskWithColumn {
191
+ return {
192
+ ...row,
193
+ providerId: row.id,
194
+ externalRef: row.id,
195
+ url: null,
196
+ assignees: row.assignee ? [row.assignee] : [],
197
+ labels: [],
198
+ comment_count: commentCount,
199
+ version: String(row.revision ?? 0),
200
+ source_updated_at: null,
201
+ column_name: row.column_name ?? '',
202
+ }
203
+ }
204
+
205
+ private async commentCountsByTask(): Promise<Map<string, number>> {
206
+ await this.ready
207
+ const rows = await this.sql<{ task_id: string; count: string | number }[]>`
208
+ SELECT task_id, COUNT(*) AS count
209
+ FROM comments
210
+ GROUP BY task_id
211
+ `
212
+ return new Map(rows.map((row) => [row.task_id, Number(row.count)]))
213
+ }
214
+
215
+ private async resolveColumn(idOrName: string): Promise<Column> {
216
+ await this.ready
217
+ const [byId] = await this.sql<Column[]>`SELECT * FROM columns WHERE id = ${idOrName} LIMIT 1`
218
+ if (byId) return byId
219
+
220
+ const [byName] = await this.sql<Column[]>`
221
+ SELECT * FROM columns WHERE LOWER(name) = LOWER(${idOrName}) LIMIT 1
222
+ `
223
+ if (byName) return byName
224
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, `No column matching '${idOrName}'`)
225
+ }
226
+
227
+ private async resolveDefaultTaskColumn(): Promise<Column> {
228
+ const configured = this.config.defaultTaskColumn?.trim()
229
+ if (configured) return this.resolveColumn(configured)
230
+
231
+ await this.ready
232
+ const [backlog] = await this.sql<Column[]>`
233
+ SELECT * FROM columns WHERE LOWER(name) = 'backlog' ORDER BY position LIMIT 1
234
+ `
235
+ if (backlog) return backlog
236
+
237
+ const [first] = await this.sql<Column[]>`
238
+ SELECT * FROM columns ORDER BY position, name LIMIT 1
239
+ `
240
+ if (first) return first
241
+ throw new KanbanError(ErrorCode.COLUMN_NOT_FOUND, 'No columns are configured')
242
+ }
243
+
244
+ private async selectTask(idOrRef: string): Promise<TaskRow | null> {
245
+ const [row] = await this.sql<TaskRow[]>`
246
+ SELECT tasks.*, columns.name AS column_name
247
+ FROM tasks
248
+ JOIN columns ON columns.id = tasks.column_id
249
+ WHERE tasks.id = ${idOrRef}
250
+ LIMIT 1
251
+ `
252
+ return row ?? null
253
+ }
254
+
255
+ private async requireTask(idOrRef: string): Promise<TaskRow> {
256
+ await this.ready
257
+ const row = await this.selectTask(idOrRef)
258
+ if (!row) throw new KanbanError(ErrorCode.TASK_NOT_FOUND, `No task with id '${idOrRef}'`)
259
+ return row
260
+ }
261
+
262
+ private async insertActivity(
263
+ taskId: string,
264
+ action: ActivityEntry['action'],
265
+ fieldChanged: string | null,
266
+ oldValue: string | null,
267
+ newValue: string | null,
268
+ ): Promise<void> {
269
+ await this.sql`
270
+ INSERT INTO activity_log (id, task_id, action, field_changed, old_value, new_value, timestamp)
271
+ VALUES (${generateId('a')}, ${taskId}, ${action}, ${fieldChanged}, ${oldValue}, ${newValue}, ${nowIso()})
272
+ `
273
+ }
274
+
275
+ async getContext(): Promise<ProviderContext> {
276
+ await this.ready
277
+ return { provider: this.type, capabilities: LOCAL_CAPABILITIES, team: null }
278
+ }
279
+
280
+ async getBootstrap(): Promise<BoardBootstrap> {
281
+ await this.ready
282
+ const metrics = await this.getMetrics()
283
+ return {
284
+ provider: this.type,
285
+ capabilities: LOCAL_CAPABILITIES,
286
+ board: await this.getBoard(),
287
+ config: await this.getConfig(),
288
+ metrics,
289
+ activity: await this.getActivity(50),
290
+ team: null,
291
+ }
292
+ }
293
+
294
+ async getBoard(): Promise<BoardView> {
295
+ await this.ready
296
+ const columns = await this.listColumns()
297
+ const counts = await this.commentCountsByTask()
298
+ const rows = await this.sql<TaskRow[]>`
299
+ SELECT tasks.*, columns.name AS column_name
300
+ FROM tasks
301
+ JOIN columns ON columns.id = tasks.column_id
302
+ ORDER BY columns.position, tasks.position, tasks.created_at
303
+ `
304
+ return {
305
+ columns: columns.map((column) => ({
306
+ ...column,
307
+ tasks: rows
308
+ .filter((task) => task.column_id === column.id)
309
+ .map((task) => this.enrichTask(task, counts.get(task.id) ?? 0)),
310
+ })),
311
+ }
312
+ }
313
+
314
+ async listColumns(): Promise<Column[]> {
315
+ await this.ready
316
+ return this.sql<Column[]>`SELECT * FROM columns ORDER BY position`
317
+ }
318
+
319
+ async listTasks(filters: TaskListFilters = {}): Promise<Task[]> {
320
+ await this.ready
321
+ const rows = await this.sql<TaskRow[]>`
322
+ SELECT tasks.*, columns.name AS column_name
323
+ FROM tasks
324
+ JOIN columns ON columns.id = tasks.column_id
325
+ ORDER BY tasks.created_at
326
+ `
327
+ const counts = await this.commentCountsByTask()
328
+ let tasks = rows.map((task) => this.enrichTask(task, counts.get(task.id) ?? 0))
329
+
330
+ if (filters.column) {
331
+ const column = await this.resolveColumn(filters.column)
332
+ tasks = tasks.filter((task) => task.column_id === column.id)
333
+ }
334
+ if (filters.priority) tasks = tasks.filter((task) => task.priority === filters.priority)
335
+ if (filters.assignee) tasks = tasks.filter((task) => task.assignee === filters.assignee)
336
+ if (filters.project) tasks = tasks.filter((task) => task.project === filters.project)
337
+ if (filters.sort === 'title') tasks = [...tasks].sort((a, b) => a.title.localeCompare(b.title))
338
+ if (filters.sort === 'updated')
339
+ tasks = [...tasks].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
340
+ if (filters.limit) tasks = tasks.slice(0, filters.limit)
341
+ return tasks
342
+ }
343
+
344
+ async getTask(idOrRef: string): Promise<Task> {
345
+ const row = await this.requireTask(idOrRef)
346
+ const counts = await this.commentCountsByTask()
347
+ return this.enrichTask(row, counts.get(row.id) ?? 0)
348
+ }
349
+
350
+ async createTask(input: CreateTaskInput): Promise<Task> {
351
+ await this.ready
352
+ const priority = input.priority ?? 'medium'
353
+ assertPriority(priority)
354
+ const metadata = parseMetadata(input.metadata)
355
+ const column = input.column
356
+ ? await this.resolveColumn(input.column)
357
+ : await this.resolveDefaultTaskColumn()
358
+ const [positionRow] = await this.sql<{ next: string | number }[]>`
359
+ SELECT COALESCE(MAX(position), -1) + 1 AS next FROM tasks WHERE column_id = ${column.id}
360
+ `
361
+ const id = generateId('t')
362
+ const timestamp = nowIso()
363
+ await this.sql`
364
+ INSERT INTO tasks (
365
+ id, title, description, column_id, position, priority, assignee, project, metadata,
366
+ revision, created_at, updated_at
367
+ )
368
+ VALUES (
369
+ ${id}, ${input.title}, ${input.description ?? ''}, ${column.id}, ${Number(positionRow?.next ?? 0)},
370
+ ${priority}, ${input.assignee ?? ''}, ${input.project ?? ''}, ${metadata},
371
+ 0, ${timestamp}, ${timestamp}
372
+ )
373
+ `
374
+ await this.insertActivity(id, 'created', null, null, input.title)
375
+ return this.getTask(id)
376
+ }
377
+
378
+ async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
379
+ await this.ready
380
+ const current = await this.requireTask(idOrRef)
381
+ if (
382
+ input.expectedVersion !== undefined &&
383
+ String(current.revision ?? 0) !== input.expectedVersion
384
+ ) {
385
+ throw new KanbanError(
386
+ ErrorCode.CONFLICT,
387
+ `Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${current.revision ?? 0})`,
388
+ )
389
+ }
390
+
391
+ if (input.priority !== undefined) assertPriority(input.priority)
392
+ const metadata = input.metadata === undefined ? undefined : parseMetadata(input.metadata)
393
+ const next = {
394
+ title: input.title ?? current.title,
395
+ description: input.description ?? current.description,
396
+ priority: input.priority ?? current.priority,
397
+ assignee: input.assignee ?? current.assignee,
398
+ project: input.project ?? current.project,
399
+ metadata: metadata ?? current.metadata,
400
+ }
401
+ await this.sql`
402
+ UPDATE tasks
403
+ SET title = ${next.title},
404
+ description = ${next.description},
405
+ priority = ${next.priority},
406
+ assignee = ${next.assignee},
407
+ project = ${next.project},
408
+ metadata = ${next.metadata},
409
+ revision = revision + 1,
410
+ updated_at = ${nowIso()}
411
+ WHERE id = ${current.id}
412
+ `
413
+ if (input.title !== undefined && input.title !== current.title) {
414
+ await this.insertActivity(current.id, 'updated', 'title', current.title, input.title)
415
+ }
416
+ if (input.assignee !== undefined && input.assignee !== current.assignee) {
417
+ await this.insertActivity(
418
+ current.id,
419
+ 'assigned',
420
+ 'assignee',
421
+ current.assignee,
422
+ input.assignee,
423
+ )
424
+ }
425
+ if (input.priority !== undefined && input.priority !== current.priority) {
426
+ await this.insertActivity(
427
+ current.id,
428
+ 'prioritized',
429
+ 'priority',
430
+ current.priority,
431
+ input.priority,
432
+ )
433
+ }
434
+ return this.getTask(current.id)
435
+ }
436
+
437
+ async moveTask(idOrRef: string, columnName: string): Promise<Task> {
438
+ await this.ready
439
+ const current = await this.requireTask(idOrRef)
440
+ const column = await this.resolveColumn(columnName)
441
+ const oldColumn = current.column_name ?? current.column_id
442
+ await this.sql`
443
+ UPDATE tasks
444
+ SET column_id = ${column.id},
445
+ revision = revision + 1,
446
+ updated_at = ${nowIso()}
447
+ WHERE id = ${current.id}
448
+ `
449
+ await this.insertActivity(current.id, 'moved', 'column', oldColumn, column.name)
450
+ return this.getTask(current.id)
451
+ }
452
+
453
+ async deleteTask(idOrRef: string): Promise<Task> {
454
+ await this.ready
455
+ const task = await this.getTask(idOrRef)
456
+ await this.sql`DELETE FROM tasks WHERE id = ${task.id}`
457
+ await this.insertActivity(task.id, 'deleted', null, task.title, null)
458
+ return task
459
+ }
460
+
461
+ async listComments(idOrRef: string): Promise<TaskComment[]> {
462
+ const task = await this.requireTask(idOrRef)
463
+ return this.sql<TaskComment[]>`
464
+ SELECT * FROM comments WHERE task_id = ${task.id} ORDER BY created_at
465
+ `
466
+ }
467
+
468
+ async getComment(idOrRef: string, commentId: string): Promise<TaskComment> {
469
+ const task = await this.requireTask(idOrRef)
470
+ const [comment] = await this.sql<TaskComment[]>`
471
+ SELECT * FROM comments WHERE task_id = ${task.id} AND id = ${commentId} LIMIT 1
472
+ `
473
+ if (!comment) {
474
+ throw new KanbanError(
475
+ ErrorCode.COMMENT_NOT_FOUND,
476
+ `No comment '${commentId}' on task '${idOrRef}'`,
477
+ )
478
+ }
479
+ return comment
480
+ }
481
+
482
+ async comment(idOrRef: string, body: string): Promise<TaskComment> {
483
+ const task = await this.requireTask(idOrRef)
484
+ const id = generateId('cm')
485
+ const timestamp = nowIso()
486
+ const [comment] = await this.sql<TaskComment[]>`
487
+ INSERT INTO comments (id, task_id, body, author, created_at, updated_at)
488
+ VALUES (${id}, ${task.id}, ${body}, ${null}, ${timestamp}, ${timestamp})
489
+ RETURNING *
490
+ `
491
+ return comment!
492
+ }
493
+
494
+ async updateComment(idOrRef: string, commentId: string, body: string): Promise<TaskComment> {
495
+ const existing = await this.getComment(idOrRef, commentId)
496
+ const [comment] = await this.sql<TaskComment[]>`
497
+ UPDATE comments
498
+ SET body = ${body}, updated_at = ${nowIso()}
499
+ WHERE id = ${existing.id}
500
+ RETURNING *
501
+ `
502
+ return comment!
503
+ }
504
+
505
+ async getActivity(limit = 100, taskId?: string): Promise<ActivityEntry[]> {
506
+ await this.ready
507
+ if (taskId) {
508
+ return this.sql<ActivityRow[]>`
509
+ SELECT * FROM activity_log
510
+ WHERE task_id = ${taskId}
511
+ ORDER BY timestamp DESC
512
+ LIMIT ${limit}
513
+ `
514
+ }
515
+ return this.sql<ActivityRow[]>`
516
+ SELECT * FROM activity_log
517
+ ORDER BY timestamp DESC
518
+ LIMIT ${limit}
519
+ `
520
+ }
521
+
522
+ async getMetrics(): Promise<BoardMetrics> {
523
+ await this.ready
524
+ const [total] = await this.sql<
525
+ { count: string | number }[]
526
+ >`SELECT COUNT(*) AS count FROM tasks`
527
+ const [doneColumn] = await this.sql<{ id: string }[]>`
528
+ SELECT id FROM columns WHERE LOWER(name) = 'done' LIMIT 1
529
+ `
530
+ const [completed] = doneColumn
531
+ ? await this.sql<{ count: string | number }[]>`
532
+ SELECT COUNT(*) AS count FROM tasks WHERE column_id = ${doneColumn.id}
533
+ `
534
+ : [{ count: 0 }]
535
+ const tasksByColumn = await this.sql<{ column_name: string; count: string | number }[]>`
536
+ SELECT columns.name AS column_name, COUNT(tasks.id) AS count
537
+ FROM columns
538
+ LEFT JOIN tasks ON tasks.column_id = columns.id
539
+ GROUP BY columns.id, columns.name, columns.position
540
+ ORDER BY columns.position
541
+ `
542
+ const tasksByPriority = await this.sql<{ priority: string; count: string | number }[]>`
543
+ SELECT priority, COUNT(*) AS count FROM tasks GROUP BY priority ORDER BY priority
544
+ `
545
+ const assignees = await this.discoveredAssignees()
546
+ const projects = await this.discoveredProjects()
547
+ const totalTasks = Number(total?.count ?? 0)
548
+ const completedTasks = Number(completed?.count ?? 0)
549
+ return {
550
+ tasksByColumn: tasksByColumn.map((row) => ({
551
+ column_name: row.column_name,
552
+ count: Number(row.count),
553
+ })),
554
+ tasksByPriority: tasksByPriority.map((row) => ({
555
+ priority: row.priority,
556
+ count: Number(row.count),
557
+ })),
558
+ totalTasks,
559
+ completedTasks,
560
+ avgCompletionHours: null,
561
+ recentActivity: await this.getActivity(10),
562
+ tasksCreatedThisWeek: 0,
563
+ inProgressCount:
564
+ tasksByColumn.find((row) => row.column_name === 'in-progress')?.count === undefined
565
+ ? 0
566
+ : Number(tasksByColumn.find((row) => row.column_name === 'in-progress')!.count),
567
+ completionPercent: totalTasks === 0 ? 0 : Math.round((completedTasks / totalTasks) * 100),
568
+ assignees,
569
+ projects,
570
+ }
571
+ }
572
+
573
+ private async discoveredAssignees(): Promise<string[]> {
574
+ const rows = await this.sql<{ assignee: string }[]>`
575
+ SELECT DISTINCT assignee FROM tasks WHERE assignee != '' ORDER BY assignee
576
+ `
577
+ return rows.map((row) => row.assignee)
578
+ }
579
+
580
+ private async discoveredProjects(): Promise<string[]> {
581
+ const rows = await this.sql<{ project: string }[]>`
582
+ SELECT DISTINCT project FROM tasks WHERE project != '' ORDER BY project
583
+ `
584
+ return rows.map((row) => row.project)
585
+ }
586
+
587
+ async getConfig(): Promise<BoardConfig> {
588
+ await this.ready
589
+ return {
590
+ members: [],
591
+ projects: await this.discoveredProjects(),
592
+ provider: 'local',
593
+ discoveredAssignees: await this.discoveredAssignees(),
594
+ discoveredProjects: await this.discoveredProjects(),
595
+ }
596
+ }
597
+
598
+ async patchConfig(input: Partial<BoardConfig>): Promise<BoardConfig> {
599
+ await this.ready
600
+ const config = await this.getConfig()
601
+ return {
602
+ ...config,
603
+ members: input.members ?? config.members,
604
+ projects: input.projects ?? config.projects,
605
+ }
606
+ }
607
+
608
+ async getSyncStatus(): Promise<ProviderSyncStatus | null> {
609
+ return null
610
+ }
611
+ }
package/src/server.ts CHANGED
@@ -3,7 +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
+ import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from './sync-config'
7
7
 
8
8
  const wsClients = new Set<ServerWebSocket<unknown>>()
9
9
  const CORS_HEADERS = {
@@ -63,7 +63,7 @@ export function startServer(
63
63
  ): StartedServer {
64
64
  const distDir = join(import.meta.dir, '..', 'ui', 'dist')
65
65
  const hasStatic = existsSync(distDir)
66
- const syncIntervalMs = opts.syncIntervalMs ?? resolvePollingSyncIntervalMs()
66
+ const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_POLLING_SYNC_INTERVAL_MS
67
67
  const syncCache = provider.syncCache?.bind(provider)
68
68
  const getSyncStatus = provider.getSyncStatus?.bind(provider)
69
69
  const backgroundSync: BackgroundSyncState = {
@@ -0,0 +1,41 @@
1
+ import { getDbPath } from './db'
2
+
3
+ export type KanbanStorageMode = 'sqlite' | 'postgres'
4
+
5
+ export interface SqliteKanbanStorageConfig {
6
+ mode: 'sqlite'
7
+ sqlitePath: string
8
+ }
9
+
10
+ export interface PostgresKanbanStorageConfig {
11
+ mode: 'postgres'
12
+ databaseUrl: string
13
+ }
14
+
15
+ export type KanbanStorageConfig = SqliteKanbanStorageConfig | PostgresKanbanStorageConfig
16
+
17
+ export interface ResolveKanbanStorageOptions {
18
+ defaultSqlitePath?: string
19
+ }
20
+
21
+ export function resolveKanbanStorageConfig(
22
+ env: Record<string, string | undefined> = process.env,
23
+ options: ResolveKanbanStorageOptions = {},
24
+ ): KanbanStorageConfig {
25
+ const rawMode = (env['KANBAN_STORAGE'] ?? 'sqlite').trim().toLowerCase()
26
+ if (rawMode !== 'sqlite' && rawMode !== 'postgres') {
27
+ throw new Error(
28
+ "Unsupported KANBAN_STORAGE '" + rawMode + "'. Expected 'sqlite' or 'postgres'.",
29
+ )
30
+ }
31
+
32
+ if (rawMode === 'postgres') {
33
+ const databaseUrl = env['KANBAN_DATABASE_URL']?.trim()
34
+ if (!databaseUrl) {
35
+ throw new Error('KANBAN_DATABASE_URL is required when KANBAN_STORAGE=postgres')
36
+ }
37
+ return { mode: 'postgres', databaseUrl }
38
+ }
39
+
40
+ return { mode: 'sqlite', sqlitePath: options.defaultSqlitePath ?? getDbPath() }
41
+ }
@@ -3,7 +3,10 @@ import { ErrorCode, KanbanError } from './errors'
3
3
  export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
4
4
  export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
5
5
 
6
- export function resolvePollingSyncIntervalMs(raw = process.env['KANBAN_SYNC_INTERVAL_MS']): number {
6
+ export function resolvePollingSyncIntervalMs(
7
+ raw = process.env['KANBAN_SYNC_INTERVAL_MS'],
8
+ opts: { label?: string } = {},
9
+ ): number {
7
10
  const value = raw?.trim()
8
11
  if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
9
12
 
@@ -11,7 +14,7 @@ export function resolvePollingSyncIntervalMs(raw = process.env['KANBAN_SYNC_INTE
11
14
  if (!Number.isInteger(parsed) || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
12
15
  throw new KanbanError(
13
16
  ErrorCode.INVALID_CONFIG,
14
- `KANBAN_SYNC_INTERVAL_MS must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
17
+ `${opts.label ?? 'KANBAN_SYNC_INTERVAL_MS'} must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
15
18
  )
16
19
  }
17
20
  return parsed