@alpacachen/dsh-kanban 1.2.0 → 1.3.1

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
@@ -6,19 +6,249 @@
6
6
  *
7
7
  * 职责:
8
8
  * - 按工作区(项目)隔离:boards 以 workspaceId 为键,每个工作区一块独立看板
9
- * - 磁盘持久化:经 ctx.fs 写入 <workspaceRoot>/kanban-board-<workspaceId>.json
9
+ * - 磁盘持久化:经 ctx.fs 写入 <workspace.path>/.dsh-kanban.json
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')
@@ -45,66 +275,140 @@ export function apply(ctx) {
45
275
  ]
46
276
 
47
277
  // ---- 持久化定位 ----
48
- const root = () => {
49
- const p = getPolicy()
50
- return p && typeof p.workspaceRoot === 'string' ? p.workspaceRoot : undefined
278
+ const BOARD_FILE = '.dsh-kanban.json'
279
+ const workspaceKey = (workspace) => String(workspace.id || workspace.path)
280
+ const writePolicyFor = (workspace, session) => {
281
+ const policy = getPolicy()
282
+ const resolved =
283
+ policy && typeof policy.resolve === 'function'
284
+ ? policy.resolve(session ? { session } : {})
285
+ : { mode: (policy && policy.defaultMode) || 'workspace-write' }
286
+ return { ...resolved, workspaceRoot: workspace.path }
51
287
  }
52
- const fileName = (wsid) => 'kanban-board-' + wsid + '.json'
53
- const resolveFile = async (wsid) => {
288
+ const resolveFile = async (workspace) => {
54
289
  const fs = getFs()
55
290
  if (!fs) return null
56
291
  try {
57
- return await fs.resolve(fileName(wsid), root() ? { cwd: root() } : {})
292
+ return await fs.resolve(BOARD_FILE, { cwd: workspace.path })
58
293
  } catch (err) {
59
- console.log('dsh-kanban: 解析看板文件失败,退回内存模式:' + ((err && err.message) || err))
294
+ console.log(
295
+ 'dsh-kanban: 解析工作区看板文件失败 ' + workspaceKey(workspace) + ':' + ((err && err.message) || err),
296
+ )
60
297
  return null
61
298
  }
62
299
  }
63
- const targetOf = async (wsid) => {
64
- if (!fileTargets.has(wsid)) fileTargets.set(wsid, await resolveFile(wsid))
65
- return fileTargets.get(wsid)
300
+ const targetOf = async (workspace) => {
301
+ 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
66
308
  }
67
- const persistedFlag = (wsid) => fileTargets.has(wsid) && fileTargets.get(wsid) !== null
68
309
 
69
310
  // ---- 看板读写 ----
70
- const boardOf = async (wsid) => {
71
- let board = boards.get(wsid)
311
+
312
+ // 备份原文件为 .dsh-kanban.json.<suffix>(复制而非移动,保证原文件在写回前始终存在)
313
+ const backupFile = async (workspace, suffix, session) => {
314
+ const fs = getFs()
315
+ const target = await targetOf(workspace)
316
+ if (!fs || !target) return null
317
+ const key = workspaceKey(workspace)
318
+ try {
319
+ const text = await fs.readText(target)
320
+ const backupTarget = await fs.resolve(BOARD_FILE + '.' + suffix, { cwd: workspace.path })
321
+ await fs.writeText(backupTarget, text, undefined, undefined, writePolicyFor(workspace, session))
322
+ return backupTarget
323
+ } catch (err) {
324
+ console.log('dsh-kanban: 备份失败 ' + key + ' (' + suffix + '):' + ((err && err.message) || err))
325
+ return null
326
+ }
327
+ }
328
+
329
+ // 记录一次性警告:进入看板 warnings 队列 + 写宿主日志
330
+ const warn = (board, message) => {
331
+ if (Array.isArray(board.warnings)) board.warnings.push(message)
332
+ console.log('dsh-kanban: ' + message)
333
+ }
334
+ const takeWarnings = (board) => {
335
+ const w = Array.isArray(board.warnings) ? board.warnings : []
336
+ board.warnings = []
337
+ return w
338
+ }
339
+ const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-')
340
+
341
+ // 首次访问某工作区时从该工作区根目录加载;异常数据一律不阻塞看板可用性
342
+ const boardOf = async (workspace, session) => {
343
+ const key = workspaceKey(workspace)
344
+ let board = boards.get(key)
72
345
  if (board) return board
73
- board = { columns: [], labels: [], cards: [] }
74
- boards.set(wsid, board)
346
+ board = { schemaVersion: SCHEMA_VERSION, columns: [], labels: [], cards: [], activities: [], warnings: [] }
347
+ boards.set(key, board)
75
348
  const fs = getFs()
76
- const target = await targetOf(wsid)
349
+ const target = await targetOf(workspace)
350
+ let migrated = false
77
351
  if (fs && target) {
78
352
  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 : []
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)
365
+ }
366
+ } else {
367
+ // 损坏 / 结构无效 / 版本超前:原文件已无法安全读取,先备份再以空板继续
368
+ const suffix =
369
+ parsed.kind === 'unsupported' ? 'unsupported-v' + parsed.version : 'corrupt-' + timestamp()
370
+ await backupFile(workspace, suffix, session)
84
371
  }
85
372
  } catch (err) {
86
- // 尚无看板文件(首次使用),保留默认空板
373
+ // 尚无看板文件(首次使用)或 fs 读取异常,保留默认空板
374
+ console.log('dsh-kanban: 读取看板失败 ' + key + ':' + ((err && err.message) || err))
87
375
  }
88
376
  }
89
377
  for (const col of board.columns) bumpSeq(col.id)
90
378
  for (const card of board.cards) bumpSeq(card.id)
379
+ for (const act of board.activities) bumpSeq(act.id)
91
380
  if (board.columns.length === 0) {
92
381
  for (const title of DEFAULT_COLUMNS) board.columns.push({ id: nextId('c'), title })
93
382
  }
94
383
  if (board.labels.length === 0) {
95
384
  board.labels = DEFAULT_LABELS.map((l) => ({ ...l }))
96
385
  }
386
+ // schema 升级写回:迁移成功即落盘新版本,保证每个文件只迁移一次
387
+ if (migrated && fs && target) await save(workspace, session)
97
388
  return board
98
389
  }
99
- const save = async (wsid) => {
390
+ const save = async (workspace, session) => {
100
391
  const fs = getFs()
101
- const target = await targetOf(wsid)
102
- const board = boards.get(wsid)
392
+ const target = await targetOf(workspace)
393
+ const key = workspaceKey(workspace)
394
+ const board = boards.get(key)
103
395
  if (!fs || !target || !board) return
104
396
  try {
105
- await fs.writeText(target, JSON.stringify({ columns: board.columns, labels: board.labels, cards: board.cards }))
397
+ await fs.writeText(
398
+ target,
399
+ JSON.stringify({
400
+ schemaVersion: SCHEMA_VERSION,
401
+ columns: board.columns,
402
+ labels: board.labels,
403
+ cards: board.cards,
404
+ activities: Array.isArray(board.activities) ? board.activities : [],
405
+ }),
406
+ undefined,
407
+ undefined,
408
+ writePolicyFor(workspace, session),
409
+ )
106
410
  } catch (err) {
107
- console.log('dsh-kanban: 保存失败 ' + wsid + ':' + ((err && err.message) || err))
411
+ console.log('dsh-kanban: 保存失败 ' + key + ':' + ((err && err.message) || err))
108
412
  }
109
413
  }
110
414
 
@@ -127,7 +431,10 @@ export function apply(ctx) {
127
431
  note: c.note ?? '',
128
432
  label: c.label ?? null,
129
433
  priority: c.priority ?? null,
434
+ createdAt: typeof c.createdAt === 'string' ? c.createdAt : null,
435
+ createdBy: typeof c.createdBy === 'string' ? c.createdBy : null,
130
436
  })),
437
+ activities: Array.isArray(b.activities) ? b.activities.map((a) => ({ ...a })) : [],
131
438
  })
132
439
  const summaryOfClone = (clone) => ({
133
440
  columns: clone.columns.map((c) => ({
@@ -145,46 +452,98 @@ export function apply(ctx) {
145
452
  })),
146
453
  })
147
454
 
455
+ // 追加一条活动事件(只读日志,随看板一起落盘)
456
+ const record = (board, ev) => {
457
+ if (!Array.isArray(board.activities)) board.activities = []
458
+ board.activities.push({
459
+ id: nextId('e'),
460
+ ts: new Date().toISOString(),
461
+ ...ev,
462
+ })
463
+ if (board.activities.length > ACTIVITY_LIMIT) {
464
+ board.activities.splice(0, board.activities.length - ACTIVITY_LIMIT)
465
+ }
466
+ }
467
+
148
468
  // ---- 核心数据操作:工具与浏览器 HTTP 共用同一份逻辑 ----
149
- const dispatch = async (wsid, method, args) => {
150
- const board = await boardOf(wsid)
469
+ const dispatch = async (workspace, method, args, source, session) => {
470
+ const board = await boardOf(workspace, session)
151
471
  const a = args || {}
152
- const persisted = () => persistedFlag(wsid)
153
- const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), ...extra })
472
+ const actor = source === 'agent' ? 'agent' : 'human'
473
+ const persisted = () => persistedFlag(workspace)
474
+ const result = (extra) => ({ board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), ...extra })
154
475
 
155
476
  switch (method) {
156
477
  case 'get':
157
- return { board: cloneBoard(board), persisted: persisted(), message: 'Board loaded' }
478
+ return { board: cloneBoard(board), persisted: persisted(), warnings: takeWarnings(board), message: 'Board loaded' }
158
479
 
159
480
  case 'getCard': {
160
481
  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 }
482
+ if (!card) return { warnings: takeWarnings(board), error: 'Card not found: ' + str(a.id, '') }
483
+ return { card, warnings: takeWarnings(board) }
163
484
  }
164
485
 
165
486
  case 'addCard': {
166
487
  const col = findColumn(board, str(a.columnId, '')) || board.columns[0]
167
488
  if (!col) return result({ error: 'No list available' })
168
- board.cards.push({
489
+ const card = {
169
490
  id: nextId('k'),
170
491
  columnId: col.id,
171
492
  title: str(a.title, '').slice(0, 120) || 'Untitled card',
172
493
  note: str(a.note, '').slice(0, 500),
173
494
  label: typeof a.label === 'string' ? a.label.slice(0, 20) : undefined,
174
495
  priority: normPriority(a.priority),
496
+ createdAt: new Date().toISOString(),
497
+ createdBy: actor,
498
+ }
499
+ board.cards.push(card)
500
+ record(board, {
501
+ cardId: card.id,
502
+ type: 'card_created',
503
+ source: actor,
504
+ meta: {
505
+ title: card.title,
506
+ column: col.title,
507
+ label: card.label ?? null,
508
+ priority: card.priority ?? null,
509
+ },
175
510
  })
176
- await save(wsid)
511
+ await save(workspace, session)
177
512
  return result({ message: 'Card added to "' + col.title + '"' })
178
513
  }
179
514
 
180
515
  case 'updateCard': {
181
516
  const card = findCard(board, str(a.id, ''))
182
517
  if (card) {
518
+ const before = {
519
+ title: card.title,
520
+ note: card.note,
521
+ label: card.label ?? null,
522
+ priority: card.priority ?? null,
523
+ }
183
524
  if (typeof a.title === 'string') card.title = a.title.slice(0, 120) || card.title
184
525
  if (typeof a.note === 'string') card.note = a.note.slice(0, 500)
185
526
  if (typeof a.label === 'string') card.label = a.label.slice(0, 20) || undefined
186
527
  if (typeof a.priority === 'string') card.priority = normPriority(a.priority)
187
- await save(wsid)
528
+ const after = {
529
+ title: card.title,
530
+ note: card.note,
531
+ label: card.label ?? null,
532
+ priority: card.priority ?? null,
533
+ }
534
+ if (after.title !== before.title) {
535
+ record(board, { cardId: card.id, type: 'card_title_changed', source: actor, field: 'title', from: before.title, to: after.title })
536
+ }
537
+ if (after.note !== before.note) {
538
+ record(board, { cardId: card.id, type: 'card_note_changed', source: actor, field: 'note' })
539
+ }
540
+ if (after.label !== before.label) {
541
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: before.label, to: after.label })
542
+ }
543
+ if (after.priority !== before.priority) {
544
+ record(board, { cardId: card.id, type: 'card_priority_changed', source: actor, field: 'priority', from: before.priority, to: after.priority })
545
+ }
546
+ await save(workspace, session)
188
547
  }
189
548
  return card
190
549
  ? result({ message: 'Card updated' })
@@ -193,8 +552,12 @@ export function apply(ctx) {
193
552
 
194
553
  case 'deleteCard': {
195
554
  const id = str(a.id, '')
555
+ const card = findCard(board, id)
196
556
  board.cards = board.cards.filter((c) => c.id !== id)
197
- await save(wsid)
557
+ if (card) {
558
+ record(board, { cardId: id, type: 'card_deleted', source: actor, meta: { title: card.title } })
559
+ }
560
+ await save(workspace, session)
198
561
  return result({ message: 'Card deleted' })
199
562
  }
200
563
 
@@ -202,6 +565,8 @@ export function apply(ctx) {
202
565
  const card = findCard(board, str(a.id, ''))
203
566
  const target = findColumn(board, str(a.columnId, ''))
204
567
  if (!card || !target) return result({ error: 'Card or list not found' })
568
+ const fromCol = findColumn(board, card.columnId)
569
+ const fromTitle = fromCol ? fromCol.title : String(card.columnId)
205
570
  board.cards = board.cards.filter((c) => c.id !== card.id)
206
571
  card.columnId = target.id
207
572
  const inCol = board.cards.filter((c) => c.columnId === target.id)
@@ -211,22 +576,30 @@ export function apply(ctx) {
211
576
  const anchor = inCol[toIndex]
212
577
  if (anchor) board.cards.splice(board.cards.indexOf(anchor), 0, card)
213
578
  else board.cards.push(card)
214
- await save(wsid)
579
+ if (fromTitle !== target.title) {
580
+ record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: fromTitle, to: target.title, meta: { title: card.title } })
581
+ }
582
+ await save(workspace, session)
215
583
  return result({ message: 'Moved to "' + target.title + '"' })
216
584
  }
217
585
 
218
586
  case 'addColumn': {
219
587
  const title = str(a.title, '').slice(0, 40) || 'New list'
220
588
  board.columns.push({ id: nextId('c'), title })
221
- await save(wsid)
589
+ record(board, { cardId: null, type: 'column_added', source: actor, meta: { column: title } })
590
+ await save(workspace, session)
222
591
  return result({ message: 'List added: "' + title + '"' })
223
592
  }
224
593
 
225
594
  case 'renameColumn': {
226
595
  const col = findColumn(board, str(a.id, ''))
227
596
  if (col && typeof a.title === 'string') {
597
+ const before = col.title
228
598
  col.title = a.title.slice(0, 40) || col.title
229
- await save(wsid)
599
+ if (col.title !== before) {
600
+ record(board, { cardId: null, type: 'column_renamed', source: actor, field: 'title', from: before, to: col.title, meta: { column: col.title } })
601
+ }
602
+ await save(workspace, session)
230
603
  }
231
604
  return col ? result({ message: 'List renamed' }) : result({ error: 'List not found' })
232
605
  }
@@ -236,12 +609,17 @@ export function apply(ctx) {
236
609
  if (board.columns.length <= 1) return result({ error: 'At least one list must remain' })
237
610
  const idx = board.columns.findIndex((c) => c.id === id)
238
611
  if (idx < 0) return result({ error: 'List not found' })
612
+ const deleted = board.columns[idx]
239
613
  board.columns.splice(idx, 1)
240
614
  const fallback = board.columns[0].id
241
615
  for (const card of board.cards) {
242
- if (card.columnId === id) card.columnId = fallback
616
+ if (card.columnId === id) {
617
+ card.columnId = fallback
618
+ record(board, { cardId: card.id, type: 'card_moved', source: actor, field: 'columnId', from: deleted.title, to: board.columns[0].title, meta: { title: card.title } })
619
+ }
243
620
  }
244
- await save(wsid)
621
+ record(board, { cardId: null, type: 'column_deleted', source: actor, meta: { column: deleted.title } })
622
+ await save(workspace, session)
245
623
  return result({ message: 'List deleted, cards moved to "' + board.columns[0].title + '"' })
246
624
  }
247
625
 
@@ -254,7 +632,7 @@ export function apply(ctx) {
254
632
  ? Math.max(0, Math.min(Math.floor(a.toIndex), board.columns.length))
255
633
  : board.columns.length
256
634
  board.columns.splice(toIndex, 0, col)
257
- await save(wsid)
635
+ await save(workspace, session)
258
636
  return result({ message: 'List order updated' })
259
637
  }
260
638
 
@@ -263,7 +641,8 @@ export function apply(ctx) {
263
641
  if (!name) return result({ error: 'Label name required' })
264
642
  if (findLabel(board, name)) return result({ error: 'Label already exists' })
265
643
  board.labels.push({ name, color: normColor(a.color) || '#94a3b8' })
266
- await save(wsid)
644
+ record(board, { cardId: null, type: 'label_added', source: actor, meta: { label: name } })
645
+ await save(workspace, session)
267
646
  return result({ message: 'Label added: "' + name + '"' })
268
647
  }
269
648
 
@@ -276,11 +655,21 @@ export function apply(ctx) {
276
655
  if (findLabel(board, newName)) return result({ error: 'Label name already exists' })
277
656
  label.name = newName
278
657
  for (const card of board.cards) {
279
- if (card.label === name) card.label = newName
658
+ if (card.label === name) {
659
+ card.label = newName
660
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: name, to: newName, meta: { title: card.title } })
661
+ }
280
662
  }
663
+ record(board, { cardId: null, type: 'label_renamed', source: actor, field: 'name', from: name, to: newName, meta: { label: newName } })
281
664
  }
282
- if (typeof a.color === 'string') label.color = normColor(a.color) || label.color
283
- await save(wsid)
665
+ if (typeof a.color === 'string') {
666
+ const beforeColor = label.color
667
+ label.color = normColor(a.color) || label.color
668
+ if (label.color !== beforeColor) {
669
+ record(board, { cardId: null, type: 'label_color_changed', source: actor, field: 'color', from: beforeColor, to: label.color, meta: { label: label.name } })
670
+ }
671
+ }
672
+ await save(workspace, session)
284
673
  return result({ message: 'Label updated' })
285
674
  }
286
675
 
@@ -290,9 +679,13 @@ export function apply(ctx) {
290
679
  if (idx < 0) return result({ error: 'Label not found' })
291
680
  board.labels.splice(idx, 1)
292
681
  for (const card of board.cards) {
293
- if (card.label === name) card.label = undefined
682
+ if (card.label === name) {
683
+ card.label = undefined
684
+ record(board, { cardId: card.id, type: 'card_label_changed', source: actor, field: 'label', from: name, to: null, meta: { title: card.title } })
685
+ }
294
686
  }
295
- await save(wsid)
687
+ record(board, { cardId: null, type: 'label_deleted', source: actor, meta: { label: name } })
688
+ await save(workspace, session)
296
689
  return result({ message: 'Label deleted' })
297
690
  }
298
691
 
@@ -301,28 +694,48 @@ export function apply(ctx) {
301
694
  }
302
695
  }
303
696
 
304
- // ---- 工具执行上下文 -> 工作区 id ----
305
- const wsidOfExec = async (exec) => {
697
+ // ---- 工具执行上下文 / 浏览器 workspaceId -> 工作区 ----
698
+ const workspaceOfExec = async (exec) => {
306
699
  const agent = exec && exec.agent
307
- const cwd = agent && agent.session && agent.session.header && agent.session.header.cwd
308
- if (typeof cwd === 'string' && cwd) {
309
- const registry = getWorkspaceRegistry()
310
- if (registry) {
311
- try {
312
- const ws = await registry.resolveByPath(cwd)
313
- if (ws) return ws.id
314
- } catch (err) {
315
- console.log('dsh-kanban: 解析工作区失败:' + ((err && err.message) || err))
316
- }
700
+ const session = agent && agent.session
701
+ const cwd = session && session.header && session.header.cwd
702
+ if (typeof cwd !== 'string' || !cwd) return null
703
+ const registry = getWorkspaceRegistry()
704
+ if (registry) {
705
+ try {
706
+ const workspace = await registry.resolveByPath(cwd)
707
+ if (workspace) return workspace
708
+ } catch (err) {
709
+ console.log('dsh-kanban: 解析工作区失败:' + ((err && err.message) || err))
317
710
  }
318
711
  }
319
- return 'default'
712
+ // 未登记到 WorkspaceRegistry 的会话仍以自身 cwd 作为工作区根目录。
713
+ return { id: 'cwd:' + cwd, path: cwd, title: cwd }
714
+ }
715
+ const workspaceOfId = (id) => {
716
+ const registry = getWorkspaceRegistry()
717
+ return registry && typeof registry.get === 'function' ? registry.get(id) : undefined
320
718
  }
719
+ const emptyBoardSummary = { columns: [], labels: [], cards: [] }
321
720
 
322
721
  const runTool = async (method, args, exec) => {
323
- 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) }
722
+ const workspace = await workspaceOfExec(exec)
723
+ if (!workspace) {
724
+ return {
725
+ ok: false,
726
+ message: 'Cannot determine the current workspace from the tool execution context',
727
+ board: emptyBoardSummary,
728
+ warnings: [],
729
+ }
730
+ }
731
+ 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 : [],
738
+ }
326
739
  }
327
740
 
328
741
  // ---- 浏览器数据层:经官方 webServer 扩展点注册 /api/kanban ----
@@ -334,8 +747,14 @@ export function apply(ctx) {
334
747
  const body = raw ? JSON.parse(raw) : {}
335
748
  const method = typeof body.method === 'string' ? body.method : 'get'
336
749
  const args = body.args || {}
337
- const wsid = typeof args.workspaceId === 'string' && args.workspaceId ? args.workspaceId : 'default'
338
- const result = await dispatch(wsid, method, args)
750
+ const workspaceId = typeof args.workspaceId === 'string' ? args.workspaceId : ''
751
+ 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
+ }
757
+ const result = await dispatch(workspace, method, args, 'human')
339
758
  res.writeHead(200, { 'content-type': 'application/json' })
340
759
  res.end(JSON.stringify(result))
341
760
  } catch (err) {
@@ -364,6 +783,7 @@ export function apply(ctx) {
364
783
  routeState.timer = timer.interval(() => {
365
784
  routeState.attempts++
366
785
  registerRoute()
786
+ maybeStartupCheck()
367
787
  if (routeState.registered || routeState.attempts >= 40) {
368
788
  if (routeState.timer) routeState.timer()
369
789
  }
@@ -371,6 +791,78 @@ export function apply(ctx) {
371
791
  }
372
792
  }
373
793
 
794
+ // ---- 启动校验:逐个工作区体检 .dsh-kanban.json(只读 + 备份损坏文件)----
795
+ const startupState = { done: false }
796
+ const maybeStartupCheck = () => {
797
+ if (startupState.done) return
798
+ const registry = getWorkspaceRegistry()
799
+ if (!getFs() || !registry || typeof registry.list !== 'function') return
800
+ startupState.done = true
801
+ runStartupCheck()
802
+ }
803
+ const runStartupCheck = async () => {
804
+ const fs = getFs()
805
+ const registry = getWorkspaceRegistry()
806
+ if (!fs || !registry || typeof registry.list !== 'function') return
807
+ try {
808
+ const workspaces = registry.list()
809
+ let found = 0
810
+ let corrupt = 0
811
+ let pendingUpgrade = 0
812
+ let unsupported = 0
813
+ let ok = 0
814
+ for (const workspace of workspaces) {
815
+ const key = workspaceKey(workspace)
816
+ try {
817
+ const target = await fs.resolve(BOARD_FILE, { cwd: workspace.path })
818
+ const text = await fs.readText(target)
819
+ found++
820
+ const parsed = parseBoardText(text)
821
+ if (parsed.ok) {
822
+ if (parsed.migrated) {
823
+ pendingUpgrade++
824
+ console.log('dsh-kanban: 启动校验 ' + key + ':schemaVersion ' + parsed.fromVersion + ',首次打开时将自动升级')
825
+ } else {
826
+ ok++
827
+ }
828
+ } else if (parsed.kind === 'unsupported') {
829
+ unsupported++
830
+ console.log('dsh-kanban: 启动校验 ' + key + ':文件由更新版本插件写入(schemaVersion ' + parsed.version + ')')
831
+ } else {
832
+ corrupt++
833
+ const suffix = 'corrupt-' + timestamp()
834
+ const backupTarget = await fs.resolve(BOARD_FILE + '.' + suffix, { cwd: workspace.path })
835
+ await fs.writeText(backupTarget, text, undefined, undefined, writePolicyFor(workspace))
836
+ console.log('dsh-kanban: 启动校验 ' + key + ':数据文件损坏(' + parsed.kind + '),已备份为 ' + BOARD_FILE + '.' + suffix)
837
+ }
838
+ } catch (err) {
839
+ if (!err || (err.code !== 'ENOENT' && err.message !== 'ENOENT')) {
840
+ console.log('dsh-kanban: 启动校验 ' + key + ':检查失败 ' + ((err && err.message) || err))
841
+ }
842
+ }
843
+ }
844
+ if (found === 0) {
845
+ console.log('dsh-kanban: 启动校验完成,未发现看板数据文件')
846
+ return
847
+ }
848
+ console.log(
849
+ 'dsh-kanban: 启动校验完成:共 ' +
850
+ found +
851
+ ' 个看板文件,正常 ' +
852
+ ok +
853
+ ',损坏并已备份 ' +
854
+ corrupt +
855
+ ',待自动升级 ' +
856
+ pendingUpgrade +
857
+ ',版本超前 ' +
858
+ unsupported,
859
+ )
860
+ } catch (err) {
861
+ console.log('dsh-kanban: 启动校验失败:' + ((err && err.message) || err))
862
+ }
863
+ }
864
+ maybeStartupCheck()
865
+
374
866
  // ---- 工具注册 ----
375
867
  const resultSchema = {
376
868
  type: 'object',
@@ -378,13 +870,18 @@ export function apply(ctx) {
378
870
  ok: { type: 'boolean' },
379
871
  message: { type: 'string' },
380
872
  board: { type: 'object' },
873
+ warnings: { type: 'array', items: { type: 'string' } },
381
874
  },
382
875
  required: ['ok', 'message'],
383
876
  additionalProperties: false,
384
877
  }
385
878
  const renderBoard = (value) => {
386
879
  const b = value && value.board
387
- const lines = [String((value && value.message) || '')]
880
+ const lines = []
881
+ if (Array.isArray(value && value.warnings)) {
882
+ for (const w of value.warnings) lines.push('⚠ ' + w)
883
+ }
884
+ lines.push(String((value && value.message) || ''))
388
885
  if (b && Array.isArray(b.columns)) {
389
886
  lines.push('Board state:')
390
887
  for (const col of b.columns) {
@@ -435,13 +932,18 @@ export function apply(ctx) {
435
932
  ok: { type: 'boolean' },
436
933
  message: { type: 'string' },
437
934
  card: { type: 'object' },
935
+ warnings: { type: 'array', items: { type: 'string' } },
438
936
  },
439
937
  required: ['ok', 'message'],
440
938
  additionalProperties: false,
441
939
  },
442
940
  render: (args, value) => {
443
941
  const c = value && value.card
444
- const lines = [String((value && value.message) || '')]
942
+ const lines = []
943
+ if (Array.isArray(value && value.warnings)) {
944
+ for (const w of value.warnings) lines.push('⚠ ' + w)
945
+ }
946
+ lines.push(String((value && value.message) || ''))
445
947
  if (c) {
446
948
  lines.push('[' + c.id + '] ' + c.title)
447
949
  if (c.priority) lines.push('Priority: ' + c.priority)
@@ -452,10 +954,12 @@ export function apply(ctx) {
452
954
  },
453
955
  },
454
956
  async execute(args, exec) {
455
- 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 }
957
+ const workspace = await workspaceOfExec(exec)
958
+ if (!workspace) return { ok: false, message: 'Cannot determine the current workspace from the tool execution context', warnings: [] }
959
+ const session = exec && exec.agent && exec.agent.session
960
+ const r = await dispatch(workspace, 'getCard', args, 'agent', session)
961
+ if (r.error) return { ok: false, message: r.error, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
962
+ return { ok: true, message: 'Card ' + String(args.id || '') + ' details', card: r.card, warnings: Array.isArray(r.warnings) ? r.warnings : [] }
459
963
  },
460
964
  },
461
965
  {
@@ -641,13 +1145,18 @@ export function apply(ctx) {
641
1145
  ok: { type: 'boolean' },
642
1146
  message: { type: 'string' },
643
1147
  labels: { type: 'array', items: { type: 'object' } },
1148
+ warnings: { type: 'array', items: { type: 'string' } },
644
1149
  },
645
1150
  required: ['ok', 'message'],
646
1151
  additionalProperties: false,
647
1152
  },
648
1153
  render: (args, value) => {
649
1154
  const labels = value && value.labels
650
- const lines = [String((value && value.message) || '')]
1155
+ const lines = []
1156
+ if (Array.isArray(value && value.warnings)) {
1157
+ for (const w of value.warnings) lines.push('⚠ ' + w)
1158
+ }
1159
+ lines.push(String((value && value.message) || ''))
651
1160
  if (Array.isArray(labels)) {
652
1161
  for (const l of labels) lines.push('- ' + l.name + ' (' + l.color + ')')
653
1162
  }
@@ -655,10 +1164,17 @@ export function apply(ctx) {
655
1164
  },
656
1165
  },
657
1166
  async execute(args, exec) {
658
- const wsid = await wsidOfExec(exec)
659
- const r = await dispatch(wsid, 'get', args)
1167
+ const workspace = await workspaceOfExec(exec)
1168
+ if (!workspace) return { ok: false, message: 'Cannot determine the current workspace from the tool execution context', labels: [], warnings: [] }
1169
+ const session = exec && exec.agent && exec.agent.session
1170
+ const r = await dispatch(workspace, 'get', args, 'agent', session)
660
1171
  const labels = (r.board && r.board.labels) || []
661
- return { ok: true, message: 'Labels (workspace ' + wsid + ')', labels }
1172
+ return {
1173
+ ok: true,
1174
+ message: 'Labels (workspace ' + workspaceKey(workspace) + ')',
1175
+ labels,
1176
+ warnings: Array.isArray(r.warnings) ? r.warnings : [],
1177
+ }
662
1178
  },
663
1179
  },
664
1180
  ]