@alpacachen/dsh-kanban 1.3.4 → 1.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.
Files changed (3) hide show
  1. package/index.js +227 -78
  2. package/lib/client.js +12 -12
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -144,20 +144,63 @@ export function validateBoard(data) {
144
144
  if (!Array.isArray(data.labels)) errors.push('labels must be an array')
145
145
  if (!Array.isArray(data.cards)) errors.push('cards must be an array')
146
146
  if (!Array.isArray(data.activities)) errors.push('activities must be an array')
147
+
148
+ const columnIds = new Set()
149
+ if (Array.isArray(data.columns)) {
150
+ for (const c of data.columns) {
151
+ if (!isObj(c) || typeof c.id !== 'string' || !c.id || typeof c.title !== 'string' || !c.title) {
152
+ errors.push('columns contain an invalid entry')
153
+ break
154
+ }
155
+ if (columnIds.has(c.id)) {
156
+ errors.push('duplicate column id: ' + c.id)
157
+ break
158
+ }
159
+ columnIds.add(c.id)
160
+ }
161
+ }
162
+
163
+ const labelNames = new Set()
164
+ if (Array.isArray(data.labels)) {
165
+ for (const l of data.labels) {
166
+ if (!isObj(l) || typeof l.name !== 'string' || !l.name || typeof l.color !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(l.color)) {
167
+ errors.push('labels contain an invalid entry')
168
+ break
169
+ }
170
+ if (labelNames.has(l.name)) {
171
+ errors.push('duplicate label name: ' + l.name)
172
+ break
173
+ }
174
+ labelNames.add(l.name)
175
+ }
176
+ }
177
+
147
178
  if (Array.isArray(data.cards)) {
148
179
  const seen = new Set()
149
180
  for (const c of data.cards) {
150
- if (!isObj(c) || typeof c.id !== 'string' || !c.id) {
151
- errors.push('cards contain an entry without a valid id')
181
+ if (!isObj(c) || typeof c.id !== 'string' || !c.id || typeof c.columnId !== 'string' || typeof c.title !== 'string') {
182
+ errors.push('cards contain an invalid entry')
152
183
  break
153
184
  }
154
185
  if (seen.has(c.id)) {
155
186
  errors.push('duplicate card id: ' + c.id)
156
187
  break
157
188
  }
189
+ if (!columnIds.has(c.columnId)) errors.push('card references missing column: ' + c.id)
190
+ if (c.label != null && !labelNames.has(c.label)) errors.push('card references missing label: ' + c.id)
191
+ if (c.priority != null && !['high', 'medium', 'low'].includes(c.priority)) errors.push('card has invalid priority: ' + c.id)
158
192
  seen.add(c.id)
159
193
  }
160
194
  }
195
+
196
+ if (Array.isArray(data.activities)) {
197
+ for (const a of data.activities) {
198
+ if (!isObj(a) || typeof a.id !== 'string' || !a.id || typeof a.ts !== 'string' || typeof a.type !== 'string' || !['human', 'agent'].includes(a.source)) {
199
+ errors.push('activities contain an invalid entry')
200
+ break
201
+ }
202
+ }
203
+ }
161
204
  return { ok: errors.length === 0, errors }
162
205
  }
163
206
 
@@ -255,7 +298,9 @@ export function apply(ctx) {
255
298
  const getWorkspaceRegistry = () => ctx.get('workspaceRegistry')
256
299
 
257
300
  const boards = new Map() // workspaceId -> { columns, labels, cards }
258
- const fileTargets = new Map() // workspaceId -> FsTarget | null
301
+ const boardLoads = new Map() // workspaceId -> Promise<board>,防止冷启动发布半初始化状态
302
+ const workspaceQueues = new Map() // workspaceId -> Promise,串行化完整 mutation 临界区
303
+ const fileTargets = new Map() // workspaceId -> FsTarget;解析失败不缓存,允许后续重试
259
304
  let seq = 0 // 全局自增,用于生成 cN(列)/ kN(卡)唯一 id
260
305
 
261
306
  // ---- id 生成 ----
@@ -299,13 +344,12 @@ export function apply(ctx) {
299
344
  }
300
345
  const targetOf = async (workspace) => {
301
346
  const key = workspaceKey(workspace)
302
- if (!fileTargets.has(key)) fileTargets.set(key, await resolveFile(workspace))
303
- return fileTargets.get(key)
304
- }
305
- const persistedFlag = (workspace) => {
306
- const key = workspaceKey(workspace)
307
- return fileTargets.has(key) && fileTargets.get(key) !== null
347
+ if (fileTargets.has(key)) return fileTargets.get(key)
348
+ const target = await resolveFile(workspace)
349
+ if (target) fileTargets.set(key, target)
350
+ return target
308
351
  }
352
+ const persistedFlag = (workspace) => fileTargets.has(workspaceKey(workspace))
309
353
 
310
354
  // ---- 看板读写 ----
311
355
 
@@ -338,54 +382,91 @@ export function apply(ctx) {
338
382
  }
339
383
  const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-')
340
384
 
341
- // 首次访问某工作区时从该工作区根目录加载;异常数据一律不阻塞看板可用性
385
+ const isNotFound = (err) => err && (err.code === 'ENOENT' || err.code === 'FS_NOT_FOUND' || err.message === 'ENOENT')
386
+
387
+ // 首次访问缓存初始化 Promise;完成读取、迁移和默认值建立后才发布 board。
342
388
  const boardOf = async (workspace, session) => {
343
389
  const key = workspaceKey(workspace)
344
- let board = boards.get(key)
345
- if (board) return board
346
- board = { schemaVersion: SCHEMA_VERSION, columns: [], labels: [], cards: [], activities: [], warnings: [] }
347
- boards.set(key, board)
348
- const fs = getFs()
349
- const target = await targetOf(workspace)
350
- let migrated = false
351
- if (fs && target) {
352
- try {
353
- const text = await fs.readText(target)
354
- const parsed = parseBoardText(text)
355
- for (const w of parsed.warnings) warn(board, w)
356
- if (parsed.ok) {
357
- board.columns = parsed.data.columns
358
- board.labels = parsed.data.labels
359
- board.cards = parsed.data.cards
360
- board.activities = Array.isArray(parsed.data.activities) ? parsed.data.activities : []
361
- migrated = parsed.migrated
362
- // schema 迁移场景:写回前先把迁移前的原文件备份下来,保证可回滚
363
- if (parsed.migrated) {
364
- await backupFile(workspace, 'bak-v' + parsed.fromVersion, session)
390
+ const existing = boards.get(key)
391
+ if (existing) return existing
392
+ if (boardLoads.has(key)) return boardLoads.get(key)
393
+
394
+ const load = (async () => {
395
+ const board = {
396
+ schemaVersion: SCHEMA_VERSION,
397
+ columns: [],
398
+ labels: [],
399
+ cards: [],
400
+ activities: [],
401
+ warnings: [],
402
+ readOnlyReason: null,
403
+ }
404
+ const fs = getFs()
405
+ const target = await targetOf(workspace)
406
+ let migrated = false
407
+ if (fs && target) {
408
+ try {
409
+ const text = await fs.readText(target)
410
+ const parsed = parseBoardText(text)
411
+ for (const w of parsed.warnings) warn(board, w)
412
+ if (parsed.ok) {
413
+ board.columns = parsed.data.columns
414
+ board.labels = parsed.data.labels
415
+ board.cards = parsed.data.cards
416
+ board.activities = parsed.data.activities
417
+ migrated = parsed.migrated
418
+ if (parsed.migrated) {
419
+ const backup = await backupFile(workspace, 'bak-v' + parsed.fromVersion, session)
420
+ if (!backup) {
421
+ migrated = false
422
+ board.readOnlyReason = 'Board migration backup failed; changes are disabled to protect the original file.'
423
+ warn(board, board.readOnlyReason)
424
+ }
425
+ }
426
+ } else {
427
+ const suffix = parsed.kind === 'unsupported'
428
+ ? 'unsupported-v' + parsed.version
429
+ : 'corrupt-' + timestamp()
430
+ const backup = await backupFile(workspace, suffix, session)
431
+ if (parsed.kind === 'unsupported' || !backup) {
432
+ board.readOnlyReason = parsed.kind === 'unsupported'
433
+ ? 'Board was created by a newer plugin version; changes are disabled until the plugin is upgraded.'
434
+ : 'Board backup failed; changes are disabled to protect the original file.'
435
+ warn(board, board.readOnlyReason)
436
+ }
437
+ }
438
+ } catch (err) {
439
+ console.log('dsh-kanban: 读取看板失败 ' + key + ':' + ((err && err.message) || err))
440
+ if (!isNotFound(err)) {
441
+ board.readOnlyReason = 'Board could not be read; changes are disabled to protect the existing file.'
442
+ warn(board, board.readOnlyReason)
365
443
  }
366
- } else {
367
- // 损坏 / 结构无效 / 版本超前:原文件已无法安全读取,先备份再以空板继续
368
- const suffix =
369
- parsed.kind === 'unsupported' ? 'unsupported-v' + parsed.version : 'corrupt-' + timestamp()
370
- await backupFile(workspace, suffix, session)
371
444
  }
372
- } catch (err) {
373
- // 尚无看板文件(首次使用)或 fs 读取异常,保留默认空板
374
- console.log('dsh-kanban: 读取看板失败 ' + key + ':' + ((err && err.message) || err))
375
445
  }
446
+ for (const col of board.columns) bumpSeq(col.id)
447
+ for (const card of board.cards) bumpSeq(card.id)
448
+ for (const act of board.activities) bumpSeq(act.id)
449
+ if (board.columns.length === 0) {
450
+ for (const title of DEFAULT_COLUMNS) board.columns.push({ id: nextId('c'), title })
451
+ }
452
+ if (board.labels.length === 0) board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
453
+ boards.set(key, board)
454
+ if (migrated && fs && target) {
455
+ try {
456
+ await save(workspace, session)
457
+ } catch (err) {
458
+ board.readOnlyReason = 'Migrated board could not be saved; changes are disabled to protect the original file.'
459
+ warn(board, board.readOnlyReason)
460
+ }
461
+ }
462
+ return board
463
+ })()
464
+ boardLoads.set(key, load)
465
+ try {
466
+ return await load
467
+ } finally {
468
+ boardLoads.delete(key)
376
469
  }
377
- for (const col of board.columns) bumpSeq(col.id)
378
- for (const card of board.cards) bumpSeq(card.id)
379
- for (const act of board.activities) bumpSeq(act.id)
380
- if (board.columns.length === 0) {
381
- for (const title of DEFAULT_COLUMNS) board.columns.push({ id: nextId('c'), title })
382
- }
383
- if (board.labels.length === 0) {
384
- board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
385
- }
386
- // schema 升级写回:迁移成功即落盘新版本,保证每个文件只迁移一次
387
- if (migrated && fs && target) await save(workspace, session)
388
- return board
389
470
  }
390
471
  const save = async (workspace, session) => {
391
472
  const fs = getFs()
@@ -409,6 +490,7 @@ export function apply(ctx) {
409
490
  )
410
491
  } catch (err) {
411
492
  console.log('dsh-kanban: 保存失败 ' + key + ':' + ((err && err.message) || err))
493
+ throw err
412
494
  }
413
495
  }
414
496
 
@@ -466,12 +548,16 @@ export function apply(ctx) {
466
548
  }
467
549
 
468
550
  // ---- 核心数据操作:工具与浏览器 HTTP 共用同一份逻辑 ----
469
- const dispatch = async (workspace, method, args, source, session) => {
551
+ const READ_METHODS = new Set(['get', 'getCard'])
552
+ const dispatchUnlocked = async (workspace, method, args, source, session) => {
470
553
  const board = await boardOf(workspace, session)
471
554
  const a = args || {}
472
555
  const actor = source === 'agent' ? 'agent' : 'human'
473
556
  const persisted = () => persistedFlag(workspace)
474
557
  const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), ...extra })
558
+ if (!READ_METHODS.has(method) && board.readOnlyReason) {
559
+ return result({ error: board.readOnlyReason })
560
+ }
475
561
 
476
562
  switch (method) {
477
563
  case 'get':
@@ -486,12 +572,14 @@ export function apply(ctx) {
486
572
  case 'addCard': {
487
573
  const col = findColumn(board, str(a.columnId, '')) || board.columns[0]
488
574
  if (!col) return result({ error: 'No list available' })
575
+ const label = typeof a.label === 'string' ? a.label.slice(0, 20) : undefined
576
+ if (label && !findLabel(board, label)) return result({ error: 'Label not found: ' + label })
489
577
  const card = {
490
578
  id: nextId('k'),
491
579
  columnId: col.id,
492
580
  title: str(a.title, '').slice(0, 120) || 'Untitled card',
493
581
  note: str(a.note, '').slice(0, 500),
494
- label: typeof a.label === 'string' ? a.label.slice(0, 20) : undefined,
582
+ label,
495
583
  priority: normPriority(a.priority),
496
584
  createdAt: new Date().toISOString(),
497
585
  createdBy: actor,
@@ -515,6 +603,8 @@ export function apply(ctx) {
515
603
  case 'updateCard': {
516
604
  const card = findCard(board, str(a.id, ''))
517
605
  if (card) {
606
+ const nextLabel = typeof a.label === 'string' ? a.label.slice(0, 20) : undefined
607
+ if (nextLabel && !findLabel(board, nextLabel)) return result({ error: 'Label not found: ' + nextLabel })
518
608
  const before = {
519
609
  title: card.title,
520
610
  note: card.note,
@@ -523,7 +613,7 @@ export function apply(ctx) {
523
613
  }
524
614
  if (typeof a.title === 'string') card.title = a.title.slice(0, 120) || card.title
525
615
  if (typeof a.note === 'string') card.note = a.note.slice(0, 500)
526
- if (typeof a.label === 'string') card.label = a.label.slice(0, 20) || undefined
616
+ if (typeof a.label === 'string') card.label = nextLabel || undefined
527
617
  if (typeof a.priority === 'string') card.priority = normPriority(a.priority)
528
618
  const after = {
529
619
  title: card.title,
@@ -553,10 +643,9 @@ export function apply(ctx) {
553
643
  case 'deleteCard': {
554
644
  const id = str(a.id, '')
555
645
  const card = findCard(board, id)
646
+ if (!card) return result({ error: 'Card not found: ' + id })
556
647
  board.cards = board.cards.filter((c) => c.id !== id)
557
- if (card) {
558
- record(board, { cardId: id, type: 'card_deleted', source: actor, meta: { title: card.title } })
559
- }
648
+ record(board, { cardId: id, type: 'card_deleted', source: actor, meta: { title: card.title } })
560
649
  await save(workspace, session)
561
650
  return result({ message: 'Card deleted' })
562
651
  }
@@ -694,6 +783,27 @@ export function apply(ctx) {
694
783
  }
695
784
  }
696
785
 
786
+ const dispatch = (workspace, method, args, source, session) => {
787
+ const key = workspaceKey(workspace)
788
+ const previous = workspaceQueues.get(key) || Promise.resolve()
789
+ const run = previous.catch(() => {}).then(async () => {
790
+ const board = await boardOf(workspace, session)
791
+ const snapshot = READ_METHODS.has(method) ? null : JSON.parse(JSON.stringify(board))
792
+ try {
793
+ return await dispatchUnlocked(workspace, method, args, source, session)
794
+ } catch (err) {
795
+ if (snapshot) boards.set(key, snapshot)
796
+ throw err
797
+ }
798
+ })
799
+ const settled = run.then(() => undefined, () => undefined)
800
+ workspaceQueues.set(key, settled)
801
+ settled.finally(() => {
802
+ if (workspaceQueues.get(key) === settled) workspaceQueues.delete(key)
803
+ })
804
+ return run
805
+ }
806
+
697
807
  // ---- 工具执行上下文 / 浏览器 workspaceId -> 工作区 ----
698
808
  const workspaceOfExec = async (exec) => {
699
809
  const agent = exec && exec.agent
@@ -729,47 +839,73 @@ export function apply(ctx) {
729
839
  }
730
840
  }
731
841
  const session = exec && exec.agent && exec.agent.session
732
- const r = await dispatch(workspace, method, args, 'agent', session)
733
- return {
734
- ok: !r.error,
735
- message: r.error || r.message || 'Done',
736
- board: summaryOfClone(r.board),
737
- warnings: Array.isArray(r.warnings) ? r.warnings : [],
842
+ try {
843
+ const r = await dispatch(workspace, method, args, 'agent', session)
844
+ return {
845
+ ok: !r.error,
846
+ message: r.error || r.message || 'Done',
847
+ board: summaryOfClone(r.board),
848
+ warnings: Array.isArray(r.warnings) ? r.warnings : [],
849
+ }
850
+ } catch (err) {
851
+ return {
852
+ ok: false,
853
+ message: 'Board change could not be persisted: ' + ((err && err.message) || err),
854
+ board: summaryOfClone(cloneBoard(await boardOf(workspace, session))),
855
+ warnings: [],
856
+ }
738
857
  }
739
858
  }
740
859
 
741
860
  // ---- 浏览器数据层:经官方 webServer 扩展点注册 /api/kanban ----
861
+ const MAX_HTTP_BODY = 1024 * 1024
862
+ const sendJson = (res, status, value) => {
863
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
864
+ res.end(JSON.stringify(value))
865
+ }
742
866
  const httpHandler = async (req, res) => {
743
867
  try {
868
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' })
869
+ const contentType = String(req.headers && req.headers['content-type'] || '').toLowerCase()
870
+ if (!contentType.startsWith('application/json')) return sendJson(res, 415, { error: 'Expected application/json' })
871
+ const origin = req.headers && req.headers.origin
872
+ const host = req.headers && req.headers.host
873
+ if (origin && host) {
874
+ let originHost = ''
875
+ try { originHost = new URL(origin).host } catch {}
876
+ if (originHost !== host) return sendJson(res, 403, { error: 'Cross-origin request denied' })
877
+ }
878
+
744
879
  const chunks = []
745
- for await (const chunk of req) chunks.push(chunk)
880
+ let size = 0
881
+ for await (const chunk of req) {
882
+ size += chunk.length
883
+ if (size > MAX_HTTP_BODY) return sendJson(res, 413, { error: 'Request body too large' })
884
+ chunks.push(chunk)
885
+ }
746
886
  const raw = Buffer.concat(chunks).toString('utf8')
747
887
  const body = raw ? JSON.parse(raw) : {}
748
888
  const method = typeof body.method === 'string' ? body.method : 'get'
749
- const args = body.args || {}
889
+ const args = isObj(body.args) ? body.args : {}
750
890
  const workspaceId = typeof args.workspaceId === 'string' ? args.workspaceId : ''
751
891
  const workspace = workspaceId ? workspaceOfId(workspaceId) : undefined
752
- if (!workspace) {
753
- res.writeHead(400, { 'content-type': 'application/json' })
754
- res.end(JSON.stringify({ error: 'Unknown workspace: ' + (workspaceId || '(missing)') }))
755
- return
756
- }
892
+ if (!workspace) return sendJson(res, 400, { error: 'Unknown workspace: ' + (workspaceId || '(missing)') })
757
893
  const result = await dispatch(workspace, method, args, 'human')
758
- res.writeHead(200, { 'content-type': 'application/json' })
759
- res.end(JSON.stringify(result))
894
+ return sendJson(res, result.error ? 400 : 200, result)
760
895
  } catch (err) {
761
- res.writeHead(500, { 'content-type': 'application/json' })
762
- res.end(JSON.stringify({ error: String((err && err.message) || err) }))
896
+ const status = err instanceof SyntaxError ? 400 : 500
897
+ return sendJson(res, status, { error: String((err && err.message) || err) })
763
898
  }
764
899
  }
765
900
 
766
- const routeState = { registered: false, timer: null, attempts: 0 }
901
+ const routeState = { registered: false, timer: null, dispose: null, disposed: false, attempts: 0 }
767
902
  const registerRoute = () => {
768
- if (routeState.registered) return
903
+ if (routeState.registered || routeState.disposed) return
769
904
  const webServer = ctx.get('webServer')
770
905
  if (webServer === undefined) return
771
906
  try {
772
- webServer.register({ kind: 'prefix', path: '/api/kanban', handler: httpHandler })
907
+ const dispose = webServer.register({ kind: 'prefix', path: '/api/kanban', handler: httpHandler })
908
+ routeState.dispose = typeof dispose === 'function' ? dispose : null
773
909
  routeState.registered = true
774
910
  console.log('dsh-kanban: /api/kanban 路由已注册')
775
911
  } catch (err) {
@@ -786,6 +922,7 @@ export function apply(ctx) {
786
922
  maybeStartupCheck()
787
923
  if (routeState.registered || routeState.attempts >= 40) {
788
924
  if (routeState.timer) routeState.timer()
925
+ routeState.timer = null
789
926
  }
790
927
  }, 500)
791
928
  }
@@ -991,7 +1128,7 @@ export function apply(ctx) {
991
1128
  title: { type: 'string', description: 'New title (optional)' },
992
1129
  note: { type: 'string', description: 'New note (optional)' },
993
1130
  label: { type: 'string', description: 'New label name (optional); pass empty string to clear' },
994
- priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'New priority (optional): high=P0 / medium=P1 / low=P2; pass empty string to clear' },
1131
+ priority: { type: 'string', enum: ['high', 'medium', 'low', ''], description: 'New priority (optional): high=P0 / medium=P1 / low=P2; pass empty string to clear' },
995
1132
  },
996
1133
  required: ['id'],
997
1134
  },
@@ -1180,4 +1317,16 @@ export function apply(ctx) {
1180
1317
  ]
1181
1318
 
1182
1319
  for (const tool of tools) ctx.tools.register(tool)
1320
+
1321
+ return () => {
1322
+ routeState.disposed = true
1323
+ if (routeState.timer) routeState.timer()
1324
+ routeState.timer = null
1325
+ if (routeState.dispose) routeState.dispose()
1326
+ routeState.dispose = null
1327
+ boards.clear()
1328
+ boardLoads.clear()
1329
+ workspaceQueues.clear()
1330
+ fileTargets.clear()
1331
+ }
1183
1332
  }