@alpacachen/dsh-kanban 1.2.0 → 1.3.0

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/index.js CHANGED
@@ -10,15 +10,245 @@
10
10
  * - 模型工具:经 ctx.tools.register 注册 14 个 kanban_* 工具
11
11
  * - 浏览器数据层:经 ctx.get('webServer') 注册 /api/kanban 前缀路由
12
12
  *
13
- * 数据模型(每工作区):
13
+ * 数据模型(每工作区,磁盘文件带 schemaVersion):
14
+ * schemaVersion: 2
14
15
  * columns: [{ id, title }]
15
16
  * labels: [{ name, color }] —— 标签与颜色绑定,name 为唯一键
16
- * cards: [{ id, columnId, title, note, label, priority }]
17
+ * cards: [{ id, columnId, title, note, label, priority, createdAt, createdBy }]
18
+ * activities: [{ id, ts, cardId, type, source, field?, from?, to?, meta? }] —— 追加式活动日志
19
+ *
20
+ * 数据安全:
21
+ * - 无 schemaVersion 的历史文件按 v0 处理,首次打开自动升级(见 MIGRATIONS)
22
+ * - 升级/损坏/版本超前均先备份原文件(.bak-vN / .corrupt-<ts> / .unsupported-vN)
23
+ * - 启动时全量体检所有看板文件,损坏文件备份后不影响看板可用性
17
24
  */
18
25
  export const name = 'dsh-kanban'
19
26
 
20
27
  export const inject = ['tools']
21
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // 持久化格式版本与迁移
31
+ //
32
+ // 磁盘文件结构:
33
+ // { schemaVersion, columns, labels, cards }
34
+ //
35
+ // 版本约定:
36
+ // v1 —— 首个带版本声明的格式,等价于 1.0.x ~ 1.2.x 时代无版本声明的三数组格式
37
+ // (columns/labels/cards),并补齐了卡片的规范字段(note/label/priority)。
38
+ // LEGACY_VERSION(0) —— 历史文件没有 schemaVersion 字段,一律归入 v0 处理。
39
+ //
40
+ // 新增/删除字段的流程:
41
+ // 1) SCHEMA_VERSION 自增
42
+ // 2) 在 MIGRATIONS 里以"旧版本号为键"注册 v(n)→v(n+1) 迁移函数
43
+ // 3) 迁移函数是纯函数,输出必须带 schemaVersion: n+1(migrateBoard 会校验)
44
+ // 旧文件在首次打开时自动沿迁移链升级,升级前原文件先备份。
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export const SCHEMA_VERSION = 2
48
+ export const LEGACY_VERSION = 0
49
+
50
+ // 活动日志:追加式只读记录,随看板一起落盘;超过上限丢弃最旧事件,防止日志无界增长。
51
+ const ACTIVITY_LIMIT = 5000
52
+
53
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
54
+ const strField = (v, fb) => (typeof v === 'string' && v ? v : fb)
55
+ const normColor = (v) => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : '#94a3b8')
56
+
57
+ // v1 的规范实体形状(仅补缺省值,不改动合法数据)
58
+ const normColumn = (c) =>
59
+ isObj(c) ? { id: strField(c.id, ''), title: strField(c.title, 'Untitled') } : null
60
+ const normLabel = (l) =>
61
+ isObj(l) ? { name: strField(l.name, ''), color: normColor(l.color) } : null
62
+ const normCard = (c) =>
63
+ isObj(c)
64
+ ? {
65
+ id: strField(c.id, ''),
66
+ columnId: strField(c.columnId, ''),
67
+ title: strField(c.title, 'Untitled'),
68
+ note: typeof c.note === 'string' ? c.note : '',
69
+ label: typeof c.label === 'string' && c.label ? c.label : null,
70
+ priority: typeof c.priority === 'string' ? c.priority : null,
71
+ }
72
+ : null
73
+
74
+ /**
75
+ * 逐版本迁移注册表:key = 旧版本号,value = (旧数据) => 新数据。
76
+ * 输出必须设置 schemaVersion = key + 1,migrateBoard 会逐级校验。
77
+ */
78
+ export const MIGRATIONS = {
79
+ 0: (data) => {
80
+ // v0 → v1:历史无版本文件 —— 声明版本 + 规范化实体字段
81
+ const src = isObj(data) ? data : {}
82
+ const pick = (arr) => (Array.isArray(arr) ? arr : [])
83
+ return {
84
+ schemaVersion: 1,
85
+ columns: pick(src.columns).map(normColumn).filter(Boolean),
86
+ labels: pick(src.labels).map(normLabel).filter(Boolean),
87
+ cards: pick(src.cards).map(normCard).filter(Boolean),
88
+ }
89
+ },
90
+ 1: (data) => {
91
+ // v1 → v2:新增追加式活动日志 activities;卡片补 createdAt/createdBy(历史数据为 null)
92
+ const src = isObj(data) ? data : {}
93
+ const pick = (arr) => (Array.isArray(arr) ? arr : [])
94
+ return {
95
+ schemaVersion: 2,
96
+ columns: pick(src.columns).map(normColumn).filter(Boolean),
97
+ labels: pick(src.labels).map(normLabel).filter(Boolean),
98
+ cards: pick(src.cards)
99
+ .map((c) => {
100
+ const card = normCard(c)
101
+ return card ? { ...card, createdAt: null, createdBy: null } : null
102
+ })
103
+ .filter(Boolean),
104
+ activities: [],
105
+ }
106
+ },
107
+ }
108
+
109
+ /**
110
+ * 沿迁移链把数据从 fromVersion 逐级升级到 SCHEMA_VERSION。
111
+ * 任一步缺失或产出无效都会抛错(由调用方备份并降级处理)。
112
+ */
113
+ export function migrateBoard(data, fromVersion) {
114
+ let out = data
115
+ let v = fromVersion
116
+ while (v < SCHEMA_VERSION) {
117
+ const step = MIGRATIONS[v]
118
+ if (typeof step !== 'function') {
119
+ throw new Error('missing migration v' + v + ' -> v' + (v + 1))
120
+ }
121
+ out = step(out)
122
+ v += 1
123
+ if (!isObj(out) || out.schemaVersion !== v) {
124
+ throw new Error('migration v' + (v - 1) + ' -> v' + v + ' produced invalid data')
125
+ }
126
+ }
127
+ return out
128
+ }
129
+
130
+ /**
131
+ * 结构校验(迁移后的最终形态)。
132
+ * 返回 { ok, errors };errors 非空时文件应视为损坏处理。
133
+ */
134
+ export function validateBoard(data) {
135
+ const errors = []
136
+ if (!isObj(data)) {
137
+ errors.push('board is not an object')
138
+ return { ok: false, errors }
139
+ }
140
+ if (data.schemaVersion !== SCHEMA_VERSION) {
141
+ errors.push('expected schemaVersion ' + SCHEMA_VERSION + ', got ' + String(data.schemaVersion))
142
+ }
143
+ if (!Array.isArray(data.columns)) errors.push('columns must be an array')
144
+ if (!Array.isArray(data.labels)) errors.push('labels must be an array')
145
+ if (!Array.isArray(data.cards)) errors.push('cards must be an array')
146
+ if (!Array.isArray(data.activities)) errors.push('activities must be an array')
147
+ if (Array.isArray(data.cards)) {
148
+ const seen = new Set()
149
+ 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')
152
+ break
153
+ }
154
+ if (seen.has(c.id)) {
155
+ errors.push('duplicate card id: ' + c.id)
156
+ break
157
+ }
158
+ seen.add(c.id)
159
+ }
160
+ }
161
+ return { ok: errors.length === 0, errors }
162
+ }
163
+
164
+ /**
165
+ * 解析并升级一段看板文件文本(纯函数,不触磁盘)。
166
+ *
167
+ * 返回:
168
+ * { ok: true, kind: 'ok', data, migrated, fromVersion, warnings } —— data 为可用的最新版
169
+ * { ok: false, kind: 'corrupt'|'invalid'|'unsupported', warnings } —— 需要调用方备份原文件
170
+ *
171
+ * kind 语义:
172
+ * corrupt —— JSON 无法解析
173
+ * invalid —— 结构校验失败 / 迁移失败
174
+ * unsupported —— schemaVersion 高于当前插件支持(文件来自更新版本插件)
175
+ */
176
+ export function parseBoardText(text) {
177
+ const warnings = []
178
+ let data
179
+ try {
180
+ data = JSON.parse(text)
181
+ } catch {
182
+ return {
183
+ ok: false,
184
+ kind: 'corrupt',
185
+ warnings: [
186
+ 'Board data file could not be parsed as JSON; the original file has been backed up and the board opened empty.',
187
+ ],
188
+ }
189
+ }
190
+
191
+ const version =
192
+ isObj(data) && typeof data.schemaVersion === 'number' ? data.schemaVersion : LEGACY_VERSION
193
+
194
+ if (version > SCHEMA_VERSION) {
195
+ return {
196
+ ok: false,
197
+ kind: 'unsupported',
198
+ version,
199
+ warnings: [
200
+ 'Board data file was written by a newer plugin version (schemaVersion ' +
201
+ version +
202
+ '); this plugin supports up to ' +
203
+ SCHEMA_VERSION +
204
+ '. The original file has been backed up and the board opened empty — please upgrade the plugin to read the original data.',
205
+ ],
206
+ }
207
+ }
208
+
209
+ let migrated = false
210
+ if (version < SCHEMA_VERSION) {
211
+ try {
212
+ data = migrateBoard(data, version)
213
+ migrated = true
214
+ warnings.push(
215
+ 'Board data was automatically upgraded from schemaVersion ' +
216
+ version +
217
+ ' to ' +
218
+ SCHEMA_VERSION +
219
+ '; the pre-upgrade file has been backed up.',
220
+ )
221
+ } catch (err) {
222
+ return {
223
+ ok: false,
224
+ kind: 'invalid',
225
+ version,
226
+ warnings: [
227
+ 'Board data upgrade failed (' +
228
+ ((err && err.message) || err) +
229
+ '); the original file has been backed up and the board opened empty.',
230
+ ],
231
+ }
232
+ }
233
+ }
234
+
235
+ const check = validateBoard(data)
236
+ if (!check.ok) {
237
+ return {
238
+ ok: false,
239
+ kind: 'invalid',
240
+ version: isObj(data) ? data.schemaVersion : undefined,
241
+ warnings: [
242
+ 'Board data file structure is invalid (' +
243
+ check.errors.join('; ') +
244
+ '); the original file has been backed up and the board opened empty.',
245
+ ],
246
+ }
247
+ }
248
+
249
+ return { ok: true, kind: 'ok', data, migrated, fromVersion: version, warnings }
250
+ }
251
+
22
252
  export function apply(ctx) {
23
253
  const getFs = () => ctx.get('fs')
24
254
  const getPolicy = () => ctx.get('sandboxPolicy')
@@ -67,33 +297,81 @@ export function apply(ctx) {
67
297
  const persistedFlag = (wsid) => fileTargets.has(wsid) && fileTargets.get(wsid) !== null
68
298
 
69
299
  // ---- 看板读写 ----
300
+
301
+ // 备份原文件为 <文件名>.<suffix>(复制而非移动,保证原文件在写回前始终存在)
302
+ const backupFile = async (wsid, suffix) => {
303
+ const fs = getFs()
304
+ const target = await targetOf(wsid)
305
+ if (!fs || !target) return null
306
+ try {
307
+ const text = await fs.readText(target)
308
+ const backupTarget = await fs.resolve(fileName(wsid) + '.' + suffix, root() ? { cwd: root() } : {})
309
+ await fs.writeText(backupTarget, text)
310
+ return backupTarget
311
+ } catch (err) {
312
+ console.log('dsh-kanban: 备份失败 ' + wsid + ' (' + suffix + '):' + ((err && err.message) || err))
313
+ return null
314
+ }
315
+ }
316
+
317
+ // 记录一次性警告:进入看板 warnings 队列 + 写宿主日志
318
+ const warn = (board, message) => {
319
+ if (Array.isArray(board.warnings)) board.warnings.push(message)
320
+ console.log('dsh-kanban: ' + message)
321
+ }
322
+ const takeWarnings = (board) => {
323
+ const w = Array.isArray(board.warnings) ? board.warnings : []
324
+ board.warnings = []
325
+ return w
326
+ }
327
+ const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-')
328
+
329
+ // 首次访问某工作区时从磁盘加载;异常数据一律不阻塞看板可用性
70
330
  const boardOf = async (wsid) => {
71
331
  let board = boards.get(wsid)
72
332
  if (board) return board
73
- board = { columns: [], labels: [], cards: [] }
333
+ board = { schemaVersion: SCHEMA_VERSION, columns: [], labels: [], cards: [], activities: [], warnings: [] }
74
334
  boards.set(wsid, board)
75
335
  const fs = getFs()
76
336
  const target = await targetOf(wsid)
337
+ let migrated = false
77
338
  if (fs && target) {
78
339
  try {
79
- const data = JSON.parse(await fs.readText(target))
80
- if (data && Array.isArray(data.columns) && Array.isArray(data.cards)) {
81
- board.columns = data.columns
82
- board.cards = data.cards
83
- board.labels = Array.isArray(data.labels) ? data.labels : []
340
+ const text = await fs.readText(target)
341
+ const parsed = parseBoardText(text)
342
+ for (const w of parsed.warnings) warn(board, w)
343
+ if (parsed.ok) {
344
+ board.columns = parsed.data.columns
345
+ board.labels = parsed.data.labels
346
+ board.cards = parsed.data.cards
347
+ board.activities = Array.isArray(parsed.data.activities) ? parsed.data.activities : []
348
+ migrated = parsed.migrated
349
+ // 迁移场景:写回前先把迁移前的原文件备份下来,保证可回滚
350
+ if (parsed.migrated) {
351
+ await backupFile(wsid, 'bak-v' + parsed.fromVersion)
352
+ }
353
+ } else {
354
+ // 损坏 / 结构无效 / 版本超前:原文件已无法安全读取,先备份再以空板继续
355
+ const suffix =
356
+ parsed.kind === 'unsupported' ? 'unsupported-v' + parsed.version : 'corrupt-' + timestamp()
357
+ await backupFile(wsid, suffix)
84
358
  }
85
359
  } catch (err) {
86
- // 尚无看板文件(首次使用),保留默认空板
360
+ // 尚无看板文件(首次使用)或 fs 读取异常,保留默认空板
361
+ console.log('dsh-kanban: 读取看板失败 ' + wsid + ':' + ((err && err.message) || err))
87
362
  }
88
363
  }
89
364
  for (const col of board.columns) bumpSeq(col.id)
90
365
  for (const card of board.cards) bumpSeq(card.id)
366
+ for (const act of board.activities) bumpSeq(act.id)
91
367
  if (board.columns.length === 0) {
92
368
  for (const title of DEFAULT_COLUMNS) board.columns.push({ id: nextId('c'), title })
93
369
  }
94
370
  if (board.labels.length === 0) {
95
371
  board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
96
372
  }
373
+ // 升级写回:迁移成功即落盘新版本,保证每个文件只迁移一次
374
+ if (migrated && fs && target) await save(wsid)
97
375
  return board
98
376
  }
99
377
  const save = async (wsid) => {
@@ -102,7 +380,16 @@ export function apply(ctx) {
102
380
  const board = boards.get(wsid)
103
381
  if (!fs || !target || !board) return
104
382
  try {
105
- await fs.writeText(target, JSON.stringify({ columns: board.columns, labels: board.labels, cards: board.cards }))
383
+ await fs.writeText(
384
+ target,
385
+ JSON.stringify({
386
+ schemaVersion: SCHEMA_VERSION,
387
+ columns: board.columns,
388
+ labels: board.labels,
389
+ cards: board.cards,
390
+ activities: Array.isArray(board.activities) ? board.activities : [],
391
+ }),
392
+ )
106
393
  } catch (err) {
107
394
  console.log('dsh-kanban: 保存失败 ' + wsid + ':' + ((err && err.message) || err))
108
395
  }
@@ -127,7 +414,10 @@ export function apply(ctx) {
127
414
  note: c.note ?? '',
128
415
  label: c.label ?? null,
129
416
  priority: c.priority ?? null,
417
+ createdAt: typeof c.createdAt === 'string' ? c.createdAt : null,
418
+ createdBy: typeof c.createdBy === 'string' ? c.createdBy : null,
130
419
  })),
420
+ activities: Array.isArray(b.activities) ? b.activities.map((a) => ({ ...a })) : [],
131
421
  })
132
422
  const summaryOfClone = (clone) => ({
133
423
  columns: clone.columns.map((c) => ({
@@ -145,33 +435,61 @@ export function apply(ctx) {
145
435
  })),
146
436
  })
147
437
 
438
+ // 追加一条活动事件(只读日志,随看板一起落盘)
439
+ const record = (board, ev) => {
440
+ if (!Array.isArray(board.activities)) board.activities = []
441
+ board.activities.push({
442
+ id: nextId('e'),
443
+ ts: new Date().toISOString(),
444
+ ...ev,
445
+ })
446
+ if (board.activities.length > ACTIVITY_LIMIT) {
447
+ board.activities.splice(0, board.activities.length - ACTIVITY_LIMIT)
448
+ }
449
+ }
450
+
148
451
  // ---- 核心数据操作:工具与浏览器 HTTP 共用同一份逻辑 ----
149
- const dispatch = async (wsid, method, args) => {
452
+ const dispatch = async (wsid, method, args, source) => {
150
453
  const board = await boardOf(wsid)
151
454
  const a = args || {}
455
+ const actor = source === 'agent' ? 'agent' : 'human'
152
456
  const persisted = () => persistedFlag(wsid)
153
- const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), ...extra })
457
+ const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), ...extra })
154
458
 
155
459
  switch (method) {
156
460
  case 'get':
157
- return { board: cloneBoard(board), persisted: persisted(), message: 'Board loaded' }
461
+ return { board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), message: 'Board loaded' }
158
462
 
159
463
  case 'getCard': {
160
464
  const card = cloneBoard(board).cards.find((c) => c.id === str(a.id, ''))
161
- if (!card) return { error: 'Card not found: ' + str(a.id, '') }
162
- return { card }
465
+ if (!card) return { warnings: takeWarnings(board), error: 'Card not found: ' + str(a.id, '') }
466
+ return { card, warnings: takeWarnings(board) }
163
467
  }
164
468
 
165
469
  case 'addCard': {
166
470
  const col = findColumn(board, str(a.columnId, '')) || board.columns[0]
167
471
  if (!col) return result({ error: 'No list available' })
168
- board.cards.push({
472
+ const card = {
169
473
  id: nextId('k'),
170
474
  columnId: col.id,
171
475
  title: str(a.title, '').slice(0, 120) || 'Untitled card',
172
476
  note: str(a.note, '').slice(0, 500),
173
477
  label: typeof a.label === 'string' ? a.label.slice(0, 20) : undefined,
174
478
  priority: normPriority(a.priority),
479
+ createdAt: new Date().toISOString(),
480
+ createdBy: actor,
481
+ }
482
+ board.cards.push(card)
483
+ record(board, {
484
+ cardId: card.id,
485
+ type: 'card_created',
486
+ source: actor,
487
+ meta: {
488
+ title: card.title,
489
+ column: col.title,
490
+ label: card.label ?? null,
491
+ priority: card.priority ?? null,
492
+ },
175
493
  })
176
494
  await save(wsid)
177
495
  return result({ message: 'Card added to "' + col.title + '"' })
@@ -180,10 +498,34 @@ export function apply(ctx) {
180
498
  case 'updateCard': {
181
499
  const card = findCard(board, str(a.id, ''))
182
500
  if (card) {
501
+ const before = {
502
+ title: card.title,
503
+ note: card.note,
504
+ label: card.label ?? null,
505
+ priority: card.priority ?? null,
506
+ }
183
507
  if (typeof a.title === 'string') card.title = a.title.slice(0, 120) || card.title
184
508
  if (typeof a.note === 'string') card.note = a.note.slice(0, 500)
185
509
  if (typeof a.label === 'string') card.label = a.label.slice(0, 20) || undefined
186
510
  if (typeof a.priority === 'string') card.priority = normPriority(a.priority)
511
+ const after = {
512
+ title: card.title,
513
+ note: card.note,
514
+ label: card.label ?? null,
515
+ priority: card.priority ?? null,
516
+ }
517
+ if (after.title !== before.title) {
518
+ record(board, { cardId: card.id, type: 'card_title_changed', source: actor, field: 'title', from: before.title, to: after.title })
519
+ }
520
+ if (after.note !== before.note) {
521
+ record(board, { cardId: card.id, type: 'card_note_changed', source: actor, field: 'note' })
522
+ }
523
+ if (after.label !== before.label) {
524
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: before.label, to: after.label })
525
+ }
526
+ if (after.priority !== before.priority) {
527
+ record(board, { cardId: card.id, type: 'card_priority_changed', source: actor, field: 'priority', from: before.priority, to: after.priority })
528
+ }
187
529
  await save(wsid)
188
530
  }
189
531
  return card
@@ -193,7 +535,11 @@ export function apply(ctx) {
193
535
 
194
536
  case 'deleteCard': {
195
537
  const id = str(a.id, '')
538
+ const card = findCard(board, id)
196
539
  board.cards = board.cards.filter((c) => c.id !== id)
540
+ if (card) {
541
+ record(board, { cardId: id, type: 'card_deleted', source: actor, meta: { title: card.title } })
542
+ }
197
543
  await save(wsid)
198
544
  return result({ message: 'Card deleted' })
199
545
  }
@@ -202,6 +548,8 @@ export function apply(ctx) {
202
548
  const card = findCard(board, str(a.id, ''))
203
549
  const target = findColumn(board, str(a.columnId, ''))
204
550
  if (!card || !target) return result({ error: 'Card or list not found' })
551
+ const fromCol = findColumn(board, card.columnId)
552
+ const fromTitle = fromCol ? fromCol.title : String(card.columnId)
205
553
  board.cards = board.cards.filter((c) => c.id !== card.id)
206
554
  card.columnId = target.id
207
555
  const inCol = board.cards.filter((c) => c.columnId === target.id)
@@ -211,6 +559,9 @@ export function apply(ctx) {
211
559
  const anchor = inCol[toIndex]
212
560
  if (anchor) board.cards.splice(board.cards.indexOf(anchor), 0, card)
213
561
  else board.cards.push(card)
562
+ if (fromTitle !== target.title) {
563
+ record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: fromTitle, to: target.title, meta: { title: card.title } })
564
+ }
214
565
  await save(wsid)
215
566
  return result({ message: 'Moved to "' + target.title + '"' })
216
567
  }
@@ -218,6 +569,7 @@ export function apply(ctx) {
218
569
  case 'addColumn': {
219
570
  const title = str(a.title, '').slice(0, 40) || 'New list'
220
571
  board.columns.push({ id: nextId('c'), title })
572
+ record(board, { cardId: null, type: 'column_added', source: actor, meta: { column: title } })
221
573
  await save(wsid)
222
574
  return result({ message: 'List added: "' + title + '"' })
223
575
  }
@@ -225,7 +577,11 @@ export function apply(ctx) {
225
577
  case 'renameColumn': {
226
578
  const col = findColumn(board, str(a.id, ''))
227
579
  if (col && typeof a.title === 'string') {
580
+ const before = col.title
228
581
  col.title = a.title.slice(0, 40) || col.title
582
+ if (col.title !== before) {
583
+ record(board, { cardId: null, type: 'column_renamed', source: actor, field: 'title', from: before, to: col.title, meta: { column: col.title } })
584
+ }
229
585
  await save(wsid)
230
586
  }
231
587
  return col ? result({ message: 'List renamed' }) : result({ error: 'List not found' })
@@ -236,11 +592,16 @@ export function apply(ctx) {
236
592
  if (board.columns.length <= 1) return result({ error: 'At least one list must remain' })
237
593
  const idx = board.columns.findIndex((c) => c.id === id)
238
594
  if (idx < 0) return result({ error: 'List not found' })
595
+ const deleted = board.columns[idx]
239
596
  board.columns.splice(idx, 1)
240
597
  const fallback = board.columns[0].id
241
598
  for (const card of board.cards) {
242
- if (card.columnId === id) card.columnId = fallback
599
+ if (card.columnId === id) {
600
+ card.columnId = fallback
601
+ record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: deleted.title, to: board.columns[0].title, meta: { title: card.title } })
602
+ }
243
603
  }
604
+ record(board, { cardId: null, type: 'column_deleted', source: actor, meta: { column: deleted.title } })
244
605
  await save(wsid)
245
606
  return result({ message: 'List deleted, cards moved to "' + board.columns[0].title + '"' })
246
607
  }
@@ -263,6 +624,7 @@ export function apply(ctx) {
263
624
  if (!name) return result({ error: 'Label name required' })
264
625
  if (findLabel(board, name)) return result({ error: 'Label already exists' })
265
626
  board.labels.push({ name, color: normColor(a.color) || '#94a3b8' })
627
+ record(board, { cardId: null, type: 'label_added', source: actor, meta: { label: name } })
266
628
  await save(wsid)
267
629
  return result({ message: 'Label added: "' + name + '"' })
268
630
  }
@@ -276,10 +638,20 @@ export function apply(ctx) {
276
638
  if (findLabel(board, newName)) return result({ error: 'Label name already exists' })
277
639
  label.name = newName
278
640
  for (const card of board.cards) {
279
- if (card.label === name) card.label = newName
641
+ if (card.label === name) {
642
+ card.label = newName
643
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: name, to: newName, meta: { title: card.title } })
644
+ }
645
+ }
646
+ record(board, { cardId: null, type: 'label_renamed', source: actor, field: 'name', from: name, to: newName, meta: { label: newName } })
647
+ }
648
+ if (typeof a.color === 'string') {
649
+ const beforeColor = label.color
650
+ label.color = normColor(a.color) || label.color
651
+ if (label.color !== beforeColor) {
652
+ record(board, { cardId: null, type: 'label_color_changed', source: actor, field: 'color', from: beforeColor, to: label.color, meta: { label: label.name } })
280
653
  }
281
654
  }
282
- if (typeof a.color === 'string') label.color = normColor(a.color) || label.color
283
655
  await save(wsid)
284
656
  return result({ message: 'Label updated' })
285
657
  }
@@ -290,8 +662,12 @@ export function apply(ctx) {
290
662
  if (idx < 0) return result({ error: 'Label not found' })
291
663
  board.labels.splice(idx, 1)
292
664
  for (const card of board.cards) {
293
- if (card.label === name) card.label = undefined
665
+ if (card.label === name) {
666
+ card.label = undefined
667
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: name, to: null, meta: { title: card.title } })
668
+ }
294
669
  }
670
+ record(board, { cardId: null, type: 'label_deleted', source: actor, meta: { label: name } })
295
671
  await save(wsid)
296
672
  return result({ message: 'Label deleted' })
297
673
  }
@@ -321,8 +697,13 @@ export function apply(ctx) {
321
697
 
322
698
  const runTool = async (method, args, exec) => {
323
699
  const wsid = await wsidOfExec(exec)
324
- const r = await dispatch(wsid, method, args)
325
- return { ok: !r.error, message: r.error || r.message || 'Done', board: summaryOfClone(r.board) }
700
+ const r = await dispatch(wsid, method, args, 'agent')
701
+ return {
702
+ ok: !r.error,
703
+ message: r.error || r.message || 'Done',
704
+ board: summaryOfClone(r.board),
705
+ warnings: Array.isArray(r.warnings) ? r.warnings : [],
706
+ }
326
707
  }
327
708
 
328
709
  // ---- 浏览器数据层:经官方 webServer 扩展点注册 /api/kanban ----
@@ -335,7 +716,7 @@ export function apply(ctx) {
335
716
  const method = typeof body.method === 'string' ? body.method : 'get'
336
717
  const args = body.args || {}
337
718
  const wsid = typeof args.workspaceId === 'string' && args.workspaceId ? args.workspaceId : 'default'
338
- const result = await dispatch(wsid, method, args)
719
+ const result = await dispatch(wsid, method, args, 'human')
339
720
  res.writeHead(200, { 'content-type': 'application/json' })
340
721
  res.end(JSON.stringify(result))
341
722
  } catch (err) {
@@ -364,6 +745,7 @@ export function apply(ctx) {
364
745
  routeState.timer = timer.interval(() => {
365
746
  routeState.attempts++
366
747
  registerRoute()
748
+ maybeStartupCheck()
367
749
  if (routeState.registered || routeState.attempts >= 40) {
368
750
  if (routeState.timer) routeState.timer()
369
751
  }
@@ -371,6 +753,76 @@ export function apply(ctx) {
371
753
  }
372
754
  }
373
755
 
756
+ // ---- 启动校验:全量体检看板文件(只读 + 备份损坏文件,不迁移、不加载内存)----
757
+ const startupState = { done: false }
758
+ const maybeStartupCheck = () => {
759
+ if (startupState.done) return
760
+ if (!getFs()) return
761
+ startupState.done = true
762
+ runStartupCheck()
763
+ }
764
+ const runStartupCheck = async () => {
765
+ const fs = getFs()
766
+ const wsRoot = root()
767
+ if (!fs || !wsRoot) return
768
+ try {
769
+ const dir = await fs.resolve('.', { cwd: wsRoot })
770
+ const entries = await fs.listDir(dir)
771
+ const boardFiles = entries.filter(
772
+ (e) => typeof e.name === 'string' && /^kanban-board-.+\.json$/.test(e.name),
773
+ )
774
+ if (boardFiles.length === 0) {
775
+ console.log('dsh-kanban: 启动校验完成,未发现看板数据文件')
776
+ return
777
+ }
778
+ let corrupt = 0
779
+ let pendingUpgrade = 0
780
+ let unsupported = 0
781
+ let ok = 0
782
+ for (const entry of boardFiles) {
783
+ const wsid = entry.name.slice('kanban-board-'.length, -'.json'.length)
784
+ try {
785
+ const text = await fs.readText(entry.target)
786
+ const parsed = parseBoardText(text)
787
+ if (parsed.ok) {
788
+ if (parsed.migrated) {
789
+ pendingUpgrade++
790
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':schemaVersion ' + parsed.fromVersion + ',首次打开时将自动升级')
791
+ } else {
792
+ ok++
793
+ }
794
+ } else if (parsed.kind === 'unsupported') {
795
+ unsupported++
796
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':文件由更新版本插件写入(schemaVersion ' + parsed.version + ')')
797
+ } else {
798
+ corrupt++
799
+ const suffix = 'corrupt-' + timestamp()
800
+ const backupTarget = await fs.resolve(entry.name + '.' + suffix, { cwd: wsRoot })
801
+ await fs.writeText(backupTarget, text)
802
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':数据文件损坏(' + parsed.kind + '),已备份为 ' + entry.name + '.' + suffix)
803
+ }
804
+ } catch (err) {
805
+ console.log('dsh-kanban: 启动校验 ' + wsid + ':检查失败 ' + ((err && err.message) || err))
806
+ }
807
+ }
808
+ console.log(
809
+ 'dsh-kanban: 启动校验完成:共 ' +
810
+ boardFiles.length +
811
+ ' 个看板文件,正常 ' +
812
+ ok +
813
+ ',损坏并已备份 ' +
814
+ corrupt +
815
+ ',待自动升级 ' +
816
+ pendingUpgrade +
817
+ ',版本超前 ' +
818
+ unsupported,
819
+ )
820
+ } catch (err) {
821
+ console.log('dsh-kanban: 启动校验失败:' + ((err && err.message) || err))
822
+ }
823
+ }
824
+ maybeStartupCheck()
825
+
374
826
  // ---- 工具注册 ----
375
827
  const resultSchema = {
376
828
  type: 'object',
@@ -378,13 +830,18 @@ export function apply(ctx) {
378
830
  ok: { type: 'boolean' },
379
831
  message: { type: 'string' },
380
832
  board: { type: 'object' },
833
+ warnings: { type: 'array', items: { type: 'string' } },
381
834
  },
382
835
  required: ['ok', 'message'],
383
836
  additionalProperties: false,
384
837
  }
385
838
  const renderBoard = (value) => {
386
839
  const b = value && value.board
387
- const lines = [String((value && value.message) || '')]
840
+ const lines = []
841
+ if (Array.isArray(value && value.warnings)) {
842
+ for (const w of value.warnings) lines.push('⚠ ' + w)
843
+ }
844
+ lines.push(String((value && value.message) || ''))
388
845
  if (b && Array.isArray(b.columns)) {
389
846
  lines.push('Board state:')
390
847
  for (const col of b.columns) {
@@ -435,13 +892,18 @@ export function apply(ctx) {
435
892
  ok: { type: 'boolean' },
436
893
  message: { type: 'string' },
437
894
  card: { type: 'object' },
895
+ warnings: { type: 'array', items: { type: 'string' } },
438
896
  },
439
897
  required: ['ok', 'message'],
440
898
  additionalProperties: false,
441
899
  },
442
900
  render: (args, value) => {
443
901
  const c = value && value.card
444
- const lines = [String((value && value.message) || '')]
902
+ const lines = []
903
+ if (Array.isArray(value && value.warnings)) {
904
+ for (const w of value.warnings) lines.push('⚠ ' + w)
905
+ }
906
+ lines.push(String((value && value.message) || ''))
445
907
  if (c) {
446
908
  lines.push('[' + c.id + '] ' + c.title)
447
909
  if (c.priority) lines.push('Priority: ' + c.priority)
@@ -453,9 +915,9 @@ export function apply(ctx) {
453
915
  },
454
916
  async execute(args, exec) {
455
917
  const wsid = await wsidOfExec(exec)
456
- const r = await dispatch(wsid, 'getCard', args)
457
- if (r.error) return { ok: false, message: r.error }
458
- return { ok: true, message: 'Card ' + String(args.id || '') + ' details', card: r.card }
918
+ const r = await dispatch(wsid, 'getCard', args, 'agent')
919
+ if (r.error) return { ok: false, message: r.error, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
920
+ return { ok: true, message: 'Card ' + String(args.id || '') + ' details', card: r.card, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
459
921
  },
460
922
  },
461
923
  {
@@ -641,13 +1103,18 @@ export function apply(ctx) {
641
1103
  ok: { type: 'boolean' },
642
1104
  message: { type: 'string' },
643
1105
  labels: { type: 'array', items: { type: 'object' } },
1106
+ warnings: { type: 'array', items: { type: 'string' } },
644
1107
  },
645
1108
  required: ['ok', 'message'],
646
1109
  additionalProperties: false,
647
1110
  },
648
1111
  render: (args, value) => {
649
1112
  const labels = value && value.labels
650
- const lines = [String((value && value.message) || '')]
1113
+ const lines = []
1114
+ if (Array.isArray(value && value.warnings)) {
1115
+ for (const w of value.warnings) lines.push('⚠ ' + w)
1116
+ }
1117
+ lines.push(String((value && value.message) || ''))
651
1118
  if (Array.isArray(labels)) {
652
1119
  for (const l of labels) lines.push('- ' + l.name + ' (' + l.color + ')')
653
1120
  }
@@ -656,9 +1123,14 @@ export function apply(ctx) {
656
1123
  },
657
1124
  async execute(args, exec) {
658
1125
  const wsid = await wsidOfExec(exec)
659
- const r = await dispatch(wsid, 'get', args)
1126
+ const r = await dispatch(wsid, 'get', args, 'agent')
660
1127
  const labels = (r.board && r.board.labels) || []
661
- return { ok: true, message: 'Labels (workspace ' + wsid + ')', labels }
1128
+ return {
1129
+ ok: true,
1130
+ message: 'Labels (workspace ' + wsid + ')',
1131
+ labels,
1132
+ warnings: Array.isArray(r.warnings) ? r.warnings : [],
1133
+ }
662
1134
  },
663
1135
  },
664
1136
  ]