@alpacachen/dsh-kanban 1.1.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/README.md +35 -2
- package/README.zh.md +35 -2
- package/index.js +544 -26
- package/lib/client.js +144 -13
- package/package.json +5 -2
package/index.js
CHANGED
|
@@ -7,18 +7,248 @@
|
|
|
7
7
|
* 职责:
|
|
8
8
|
* - 按工作区(项目)隔离:boards 以 workspaceId 为键,每个工作区一块独立看板
|
|
9
9
|
* - 磁盘持久化:经 ctx.fs 写入 <workspaceRoot>/kanban-board-<workspaceId>.json
|
|
10
|
-
* - 模型工具:经 ctx.tools.register 注册
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
board.
|
|
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(
|
|
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,27 +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' }
|
|
462
|
+
|
|
463
|
+
case 'getCard': {
|
|
464
|
+
const card = cloneBoard(board).cards.find((c) => c.id === str(a.id, ''))
|
|
465
|
+
if (!card) return { warnings: takeWarnings(board), error: 'Card not found: ' + str(a.id, '') }
|
|
466
|
+
return { card, warnings: takeWarnings(board) }
|
|
467
|
+
}
|
|
158
468
|
|
|
159
469
|
case 'addCard': {
|
|
160
470
|
const col = findColumn(board, str(a.columnId, '')) || board.columns[0]
|
|
161
471
|
if (!col) return result({ error: 'No list available' })
|
|
162
|
-
|
|
472
|
+
const card = {
|
|
163
473
|
id: nextId('k'),
|
|
164
474
|
columnId: col.id,
|
|
165
475
|
title: str(a.title, '').slice(0, 120) || 'Untitled card',
|
|
166
476
|
note: str(a.note, '').slice(0, 500),
|
|
167
477
|
label: typeof a.label === 'string' ? a.label.slice(0, 20) : undefined,
|
|
168
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
|
+
},
|
|
169
493
|
})
|
|
170
494
|
await save(wsid)
|
|
171
495
|
return result({ message: 'Card added to "' + col.title + '"' })
|
|
@@ -174,10 +498,34 @@ export function apply(ctx) {
|
|
|
174
498
|
case 'updateCard': {
|
|
175
499
|
const card = findCard(board, str(a.id, ''))
|
|
176
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
|
+
}
|
|
177
507
|
if (typeof a.title === 'string') card.title = a.title.slice(0, 120) || card.title
|
|
178
508
|
if (typeof a.note === 'string') card.note = a.note.slice(0, 500)
|
|
179
509
|
if (typeof a.label === 'string') card.label = a.label.slice(0, 20) || undefined
|
|
180
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
|
+
}
|
|
181
529
|
await save(wsid)
|
|
182
530
|
}
|
|
183
531
|
return card
|
|
@@ -187,7 +535,11 @@ export function apply(ctx) {
|
|
|
187
535
|
|
|
188
536
|
case 'deleteCard': {
|
|
189
537
|
const id = str(a.id, '')
|
|
538
|
+
const card = findCard(board, id)
|
|
190
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
|
+
}
|
|
191
543
|
await save(wsid)
|
|
192
544
|
return result({ message: 'Card deleted' })
|
|
193
545
|
}
|
|
@@ -196,6 +548,8 @@ export function apply(ctx) {
|
|
|
196
548
|
const card = findCard(board, str(a.id, ''))
|
|
197
549
|
const target = findColumn(board, str(a.columnId, ''))
|
|
198
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)
|
|
199
553
|
board.cards = board.cards.filter((c) => c.id !== card.id)
|
|
200
554
|
card.columnId = target.id
|
|
201
555
|
const inCol = board.cards.filter((c) => c.columnId === target.id)
|
|
@@ -205,6 +559,9 @@ export function apply(ctx) {
|
|
|
205
559
|
const anchor = inCol[toIndex]
|
|
206
560
|
if (anchor) board.cards.splice(board.cards.indexOf(anchor), 0, card)
|
|
207
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
|
+
}
|
|
208
565
|
await save(wsid)
|
|
209
566
|
return result({ message: 'Moved to "' + target.title + '"' })
|
|
210
567
|
}
|
|
@@ -212,6 +569,7 @@ export function apply(ctx) {
|
|
|
212
569
|
case 'addColumn': {
|
|
213
570
|
const title = str(a.title, '').slice(0, 40) || 'New list'
|
|
214
571
|
board.columns.push({ id: nextId('c'), title })
|
|
572
|
+
record(board, { cardId: null, type: 'column_added', source: actor, meta: { column: title } })
|
|
215
573
|
await save(wsid)
|
|
216
574
|
return result({ message: 'List added: "' + title + '"' })
|
|
217
575
|
}
|
|
@@ -219,7 +577,11 @@ export function apply(ctx) {
|
|
|
219
577
|
case 'renameColumn': {
|
|
220
578
|
const col = findColumn(board, str(a.id, ''))
|
|
221
579
|
if (col && typeof a.title === 'string') {
|
|
580
|
+
const before = col.title
|
|
222
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
|
+
}
|
|
223
585
|
await save(wsid)
|
|
224
586
|
}
|
|
225
587
|
return col ? result({ message: 'List renamed' }) : result({ error: 'List not found' })
|
|
@@ -230,11 +592,16 @@ export function apply(ctx) {
|
|
|
230
592
|
if (board.columns.length <= 1) return result({ error: 'At least one list must remain' })
|
|
231
593
|
const idx = board.columns.findIndex((c) => c.id === id)
|
|
232
594
|
if (idx < 0) return result({ error: 'List not found' })
|
|
595
|
+
const deleted = board.columns[idx]
|
|
233
596
|
board.columns.splice(idx, 1)
|
|
234
597
|
const fallback = board.columns[0].id
|
|
235
598
|
for (const card of board.cards) {
|
|
236
|
-
if (card.columnId === id)
|
|
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
|
+
}
|
|
237
603
|
}
|
|
604
|
+
record(board, { cardId: null, type: 'column_deleted', source: actor, meta: { column: deleted.title } })
|
|
238
605
|
await save(wsid)
|
|
239
606
|
return result({ message: 'List deleted, cards moved to "' + board.columns[0].title + '"' })
|
|
240
607
|
}
|
|
@@ -257,6 +624,7 @@ export function apply(ctx) {
|
|
|
257
624
|
if (!name) return result({ error: 'Label name required' })
|
|
258
625
|
if (findLabel(board, name)) return result({ error: 'Label already exists' })
|
|
259
626
|
board.labels.push({ name, color: normColor(a.color) || '#94a3b8' })
|
|
627
|
+
record(board, { cardId: null, type: 'label_added', source: actor, meta: { label: name } })
|
|
260
628
|
await save(wsid)
|
|
261
629
|
return result({ message: 'Label added: "' + name + '"' })
|
|
262
630
|
}
|
|
@@ -270,10 +638,20 @@ export function apply(ctx) {
|
|
|
270
638
|
if (findLabel(board, newName)) return result({ error: 'Label name already exists' })
|
|
271
639
|
label.name = newName
|
|
272
640
|
for (const card of board.cards) {
|
|
273
|
-
if (card.label === name)
|
|
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 } })
|
|
274
653
|
}
|
|
275
654
|
}
|
|
276
|
-
if (typeof a.color === 'string') label.color = normColor(a.color) || label.color
|
|
277
655
|
await save(wsid)
|
|
278
656
|
return result({ message: 'Label updated' })
|
|
279
657
|
}
|
|
@@ -284,8 +662,12 @@ export function apply(ctx) {
|
|
|
284
662
|
if (idx < 0) return result({ error: 'Label not found' })
|
|
285
663
|
board.labels.splice(idx, 1)
|
|
286
664
|
for (const card of board.cards) {
|
|
287
|
-
if (card.label === name)
|
|
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
|
+
}
|
|
288
669
|
}
|
|
670
|
+
record(board, { cardId: null, type: 'label_deleted', source: actor, meta: { label: name } })
|
|
289
671
|
await save(wsid)
|
|
290
672
|
return result({ message: 'Label deleted' })
|
|
291
673
|
}
|
|
@@ -315,8 +697,13 @@ export function apply(ctx) {
|
|
|
315
697
|
|
|
316
698
|
const runTool = async (method, args, exec) => {
|
|
317
699
|
const wsid = await wsidOfExec(exec)
|
|
318
|
-
const r = await dispatch(wsid, method, args)
|
|
319
|
-
return {
|
|
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
|
+
}
|
|
320
707
|
}
|
|
321
708
|
|
|
322
709
|
// ---- 浏览器数据层:经官方 webServer 扩展点注册 /api/kanban ----
|
|
@@ -329,7 +716,7 @@ export function apply(ctx) {
|
|
|
329
716
|
const method = typeof body.method === 'string' ? body.method : 'get'
|
|
330
717
|
const args = body.args || {}
|
|
331
718
|
const wsid = typeof args.workspaceId === 'string' && args.workspaceId ? args.workspaceId : 'default'
|
|
332
|
-
const result = await dispatch(wsid, method, args)
|
|
719
|
+
const result = await dispatch(wsid, method, args, 'human')
|
|
333
720
|
res.writeHead(200, { 'content-type': 'application/json' })
|
|
334
721
|
res.end(JSON.stringify(result))
|
|
335
722
|
} catch (err) {
|
|
@@ -358,6 +745,7 @@ export function apply(ctx) {
|
|
|
358
745
|
routeState.timer = timer.interval(() => {
|
|
359
746
|
routeState.attempts++
|
|
360
747
|
registerRoute()
|
|
748
|
+
maybeStartupCheck()
|
|
361
749
|
if (routeState.registered || routeState.attempts >= 40) {
|
|
362
750
|
if (routeState.timer) routeState.timer()
|
|
363
751
|
}
|
|
@@ -365,6 +753,76 @@ export function apply(ctx) {
|
|
|
365
753
|
}
|
|
366
754
|
}
|
|
367
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
|
+
|
|
368
826
|
// ---- 工具注册 ----
|
|
369
827
|
const resultSchema = {
|
|
370
828
|
type: 'object',
|
|
@@ -372,13 +830,18 @@ export function apply(ctx) {
|
|
|
372
830
|
ok: { type: 'boolean' },
|
|
373
831
|
message: { type: 'string' },
|
|
374
832
|
board: { type: 'object' },
|
|
833
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
375
834
|
},
|
|
376
835
|
required: ['ok', 'message'],
|
|
377
836
|
additionalProperties: false,
|
|
378
837
|
}
|
|
379
838
|
const renderBoard = (value) => {
|
|
380
839
|
const b = value && value.board
|
|
381
|
-
const lines = [
|
|
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) || ''))
|
|
382
845
|
if (b && Array.isArray(b.columns)) {
|
|
383
846
|
lines.push('Board state:')
|
|
384
847
|
for (const col of b.columns) {
|
|
@@ -412,6 +875,51 @@ export function apply(ctx) {
|
|
|
412
875
|
return runTool('get', args, exec)
|
|
413
876
|
},
|
|
414
877
|
},
|
|
878
|
+
{
|
|
879
|
+
name: 'kanban_get_card',
|
|
880
|
+
description: "Read one card's full details (title, note, label, priority) by id from the current project (workspace) board. Use kanban_get first to discover card ids, then this tool to read a card's complete note and fields.",
|
|
881
|
+
parameters: {
|
|
882
|
+
type: 'object',
|
|
883
|
+
properties: {
|
|
884
|
+
id: { type: 'string', description: 'Card id (see kanban_get output)' },
|
|
885
|
+
},
|
|
886
|
+
required: ['id'],
|
|
887
|
+
},
|
|
888
|
+
output: {
|
|
889
|
+
schema: {
|
|
890
|
+
type: 'object',
|
|
891
|
+
properties: {
|
|
892
|
+
ok: { type: 'boolean' },
|
|
893
|
+
message: { type: 'string' },
|
|
894
|
+
card: { type: 'object' },
|
|
895
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
896
|
+
},
|
|
897
|
+
required: ['ok', 'message'],
|
|
898
|
+
additionalProperties: false,
|
|
899
|
+
},
|
|
900
|
+
render: (args, value) => {
|
|
901
|
+
const c = value && value.card
|
|
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) || ''))
|
|
907
|
+
if (c) {
|
|
908
|
+
lines.push('[' + c.id + '] ' + c.title)
|
|
909
|
+
if (c.priority) lines.push('Priority: ' + c.priority)
|
|
910
|
+
if (c.label) lines.push('Label: ' + c.label)
|
|
911
|
+
if (c.note) lines.push('Note: ' + c.note)
|
|
912
|
+
}
|
|
913
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
async execute(args, exec) {
|
|
917
|
+
const wsid = await wsidOfExec(exec)
|
|
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 : [] }
|
|
921
|
+
},
|
|
922
|
+
},
|
|
415
923
|
{
|
|
416
924
|
name: 'kanban_add_card',
|
|
417
925
|
description: 'Add a card to the current project (workspace) kanban board. Write feature breakdowns and plans to the board: one card per task step.',
|
|
@@ -595,13 +1103,18 @@ export function apply(ctx) {
|
|
|
595
1103
|
ok: { type: 'boolean' },
|
|
596
1104
|
message: { type: 'string' },
|
|
597
1105
|
labels: { type: 'array', items: { type: 'object' } },
|
|
1106
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
598
1107
|
},
|
|
599
1108
|
required: ['ok', 'message'],
|
|
600
1109
|
additionalProperties: false,
|
|
601
1110
|
},
|
|
602
1111
|
render: (args, value) => {
|
|
603
1112
|
const labels = value && value.labels
|
|
604
|
-
const lines = [
|
|
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) || ''))
|
|
605
1118
|
if (Array.isArray(labels)) {
|
|
606
1119
|
for (const l of labels) lines.push('- ' + l.name + ' (' + l.color + ')')
|
|
607
1120
|
}
|
|
@@ -610,9 +1123,14 @@ export function apply(ctx) {
|
|
|
610
1123
|
},
|
|
611
1124
|
async execute(args, exec) {
|
|
612
1125
|
const wsid = await wsidOfExec(exec)
|
|
613
|
-
const r = await dispatch(wsid, 'get', args)
|
|
1126
|
+
const r = await dispatch(wsid, 'get', args, 'agent')
|
|
614
1127
|
const labels = (r.board && r.board.labels) || []
|
|
615
|
-
return {
|
|
1128
|
+
return {
|
|
1129
|
+
ok: true,
|
|
1130
|
+
message: 'Labels (workspace ' + wsid + ')',
|
|
1131
|
+
labels,
|
|
1132
|
+
warnings: Array.isArray(r.warnings) ? r.warnings : [],
|
|
1133
|
+
}
|
|
616
1134
|
},
|
|
617
1135
|
},
|
|
618
1136
|
]
|