@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/README.md +33 -0
- package/README.zh.md +33 -0
- package/index.js +503 -31
- package/lib/client.js +132 -14
- package/package.json +4 -1
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
|
|
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,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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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 {
|
|
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 = [
|
|
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 = [
|
|
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 = [
|
|
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 {
|
|
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
|
]
|