@goodandready/dsh-kanban 0.2.7 → 0.2.8
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 +14 -0
- package/README.zh.md +11 -0
- package/lib/board-tool.js +1 -1
- package/lib/client.js +163 -30
- package/lib/commands.js +6 -11
- package/lib/config.js +31 -29
- package/lib/events.js +2 -2
- package/lib/filters.js +2 -2
- package/lib/index.js +62 -51
- package/lib/power.js +1 -1
- package/lib/progress-dump.js +3 -3
- package/lib/routes.js +48 -6
- package/lib/store.js +19 -7
- package/lib/sync.js +2 -2
- package/lib/task-service.js +56 -0
- package/lib/updater.js +338 -0
- package/lib/worktree.js +1 -1
- package/package.json +5 -2
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerPluginUpdater } from './updater.js'
|
|
1
2
|
// dsh-kanban — доска задач для DeepSeek Harness.
|
|
2
3
|
//
|
|
3
4
|
// Плагин состоит из двух половин: этот модуль — серверная, `lib/client.js` —
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
|
|
58
59
|
import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
|
|
59
60
|
import { createGiteaClient } from './gitea.js'
|
|
61
|
+
import { createKanbanTaskService } from './task-service.js'
|
|
60
62
|
import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
|
|
61
63
|
import { isWatched } from './intake.js'
|
|
62
64
|
import { handleSessionEvent } from './lifecycle.js'
|
|
@@ -95,7 +97,7 @@ import { TASK_TEMPLATES } from './templates.js'
|
|
|
95
97
|
export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
|
|
96
98
|
|
|
97
99
|
/** Стабильное имя плагина cordis (строка в патче сборки). */
|
|
98
|
-
export const name = 'dsh-kanban'
|
|
100
|
+
export const name = '@goodandready/dsh-kanban'
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
103
|
* Службы добавляются там, где впервые нужны: `gitea` — вместе с импортом
|
|
@@ -135,14 +137,14 @@ export const Config = z.object(Object.fromEntries(
|
|
|
135
137
|
|
|
136
138
|
/** Каталог хранилища. Абсолютных путей в коде нет — только переменная и дом. */
|
|
137
139
|
export function storeDir() {
|
|
138
|
-
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
|
|
140
|
+
return process.env.DSH_KANBAN_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
function writeJson(res, code, body) {
|
|
142
144
|
try {
|
|
143
145
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
144
146
|
res.end(JSON.stringify(body))
|
|
145
|
-
} catch { /*
|
|
147
|
+
} catch { /* socket closed */ }
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
/** Ответ обработчика: либо объект с `error` и `status`, либо полезная нагрузка. */
|
|
@@ -161,7 +163,7 @@ function readBody(req) {
|
|
|
161
163
|
req.on('data', (c) => {
|
|
162
164
|
size += c.length
|
|
163
165
|
if (size > MAX_BODY_BYTES) {
|
|
164
|
-
reject(Object.assign(new Error('
|
|
166
|
+
reject(Object.assign(new Error('Payload too large'), { status: 413 }))
|
|
165
167
|
req.destroy()
|
|
166
168
|
return
|
|
167
169
|
}
|
|
@@ -170,7 +172,7 @@ function readBody(req) {
|
|
|
170
172
|
req.on('end', () => {
|
|
171
173
|
const raw = Buffer.concat(chunks).toString('utf8')
|
|
172
174
|
if (raw.trim() === '') { resolve({}); return }
|
|
173
|
-
try { resolve(JSON.parse(raw)) } catch { reject(Object.assign(new Error('
|
|
175
|
+
try { resolve(JSON.parse(raw)) } catch { reject(Object.assign(new Error('Malformed JSON payload'), { status: 400 })) }
|
|
174
176
|
})
|
|
175
177
|
req.on('error', reject)
|
|
176
178
|
})
|
|
@@ -190,7 +192,7 @@ function readRaw(req) {
|
|
|
190
192
|
req.on('data', (c) => {
|
|
191
193
|
size += c.length
|
|
192
194
|
if (size > MAX_BODY_BYTES) {
|
|
193
|
-
reject(Object.assign(new Error('
|
|
195
|
+
reject(Object.assign(new Error('Payload too large'), { status: 413 }))
|
|
194
196
|
req.destroy()
|
|
195
197
|
return
|
|
196
198
|
}
|
|
@@ -215,7 +217,7 @@ export function apply(ctx, config) {
|
|
|
215
217
|
const rearmers = new Set()
|
|
216
218
|
const rearm = () => {
|
|
217
219
|
for (const again of rearmers) {
|
|
218
|
-
try { again() } catch (error) { ctx.logger?.warn?.(`dsh-kanban:
|
|
220
|
+
try { again() } catch (error) { ctx.logger?.warn?.(`dsh-kanban: rearm failed: ${error?.message}`) }
|
|
219
221
|
}
|
|
220
222
|
}
|
|
221
223
|
|
|
@@ -223,7 +225,7 @@ export function apply(ctx, config) {
|
|
|
223
225
|
const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
|
|
224
226
|
const read = () => withDefaults(scope.get() ?? effective)
|
|
225
227
|
effective = read()
|
|
226
|
-
ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban:
|
|
228
|
+
ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
|
|
227
229
|
})
|
|
228
230
|
|
|
229
231
|
// Клиент Gitea свой: токен всё равно живёт в службе учётных данных ядра, а
|
|
@@ -247,10 +249,12 @@ export function apply(ctx, config) {
|
|
|
247
249
|
// Хранилище открывается один раз и закрывается уборщиком того же эффекта:
|
|
248
250
|
// выгрузка плагина обязана отпустить файл базы.
|
|
249
251
|
let store
|
|
252
|
+
const taskService = createKanbanTaskService({ getStore: () => store })
|
|
253
|
+
if (typeof ctx.provide === 'function') ctx.provide('dshKanban', taskService)
|
|
250
254
|
ctx.effect(() => {
|
|
251
|
-
store = openStore({ dir: storeDir() })
|
|
252
|
-
return () => { try { store.close() } catch { /*
|
|
253
|
-
}, 'dsh-kanban:
|
|
255
|
+
store = openStore({ dir: config?.storeDir || storeDir() })
|
|
256
|
+
return () => { try { store.close() } catch { /* already closed */ } }
|
|
257
|
+
}, 'dsh-kanban: task store')
|
|
254
258
|
|
|
255
259
|
// Кроссплатформенный ингибитор сна системы во время работы задач
|
|
256
260
|
const powerInhibitor = new PowerInhibitor({
|
|
@@ -266,19 +270,19 @@ export function apply(ctx, config) {
|
|
|
266
270
|
const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
|
|
267
271
|
powerInhibitor.setReasons(activeCount)
|
|
268
272
|
}
|
|
269
|
-
} catch { /*
|
|
273
|
+
} catch { /* ignore */ }
|
|
270
274
|
}
|
|
271
275
|
rearmers.add(syncPower)
|
|
272
276
|
ctx.effect(() => {
|
|
273
277
|
syncPower()
|
|
274
278
|
return () => { powerInhibitor.destroy() }
|
|
275
|
-
}, 'dsh-kanban:
|
|
279
|
+
}, 'dsh-kanban: power inhibitor')
|
|
276
280
|
|
|
277
281
|
// Инъекция секции доски в системный промпт агента
|
|
278
282
|
let disposePromptSection
|
|
279
283
|
const syncPromptSection = () => {
|
|
280
284
|
if (disposePromptSection) {
|
|
281
|
-
try { disposePromptSection() } catch {}
|
|
285
|
+
try { disposePromptSection() } catch { /* cleanup best effort */ }
|
|
282
286
|
disposePromptSection = undefined
|
|
283
287
|
}
|
|
284
288
|
const cfg = getConfig()
|
|
@@ -288,18 +292,18 @@ export function apply(ctx, config) {
|
|
|
288
292
|
disposePromptSection = sp.section({
|
|
289
293
|
name: 'plugin:dsh-kanban',
|
|
290
294
|
order: 200,
|
|
291
|
-
text: '
|
|
295
|
+
text: 'The dsh-kanban task board is available in the system. When working on tasks, you can move cards using the board_move tool and manage the plan checklist via board_plan. When context is running low or a milestone is reached, output a <<<PROGRESSDUMP\nGoal: ...\nProgress: ...\nNext steps: ...\n>>>PROGRESSDUMP block to hand over to the next session.',
|
|
292
296
|
})
|
|
293
297
|
} catch (err) {
|
|
294
|
-
ctx.logger?.warn?.(`dsh-kanban:
|
|
298
|
+
ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
|
|
295
299
|
}
|
|
296
300
|
}
|
|
297
301
|
}
|
|
298
302
|
rearmers.add(syncPromptSection)
|
|
299
303
|
ctx.effect(() => {
|
|
300
304
|
syncPromptSection()
|
|
301
|
-
return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
|
|
302
|
-
}, 'dsh-kanban:
|
|
305
|
+
return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
|
|
306
|
+
}, 'dsh-kanban: system prompt section')
|
|
303
307
|
|
|
304
308
|
// Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
|
|
305
309
|
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
@@ -319,10 +323,10 @@ export function apply(ctx, config) {
|
|
|
319
323
|
let existingTask = store?.findTaskBySession?.(sessionIdStr)
|
|
320
324
|
if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
|
|
321
325
|
const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
|
|
322
|
-
const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) :
|
|
326
|
+
const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Dialog ${sessionIdStr.slice(0, 8)}`)
|
|
323
327
|
const createdTask = store.createTask({
|
|
324
328
|
title: sessionTitle,
|
|
325
|
-
body: userText ||
|
|
329
|
+
body: userText || `Automatically created from chat session ${sessionIdStr}`,
|
|
326
330
|
column: 'in-progress',
|
|
327
331
|
board: 'main',
|
|
328
332
|
sessionId: sessionIdStr,
|
|
@@ -372,9 +376,9 @@ export function apply(ctx, config) {
|
|
|
372
376
|
}).catch(() => {})
|
|
373
377
|
}
|
|
374
378
|
} catch (error) {
|
|
375
|
-
ctx.logger?.warn?.(`dsh-kanban:
|
|
379
|
+
ctx.logger?.warn?.(`dsh-kanban: session event handler failed: ${error?.message}`)
|
|
376
380
|
}
|
|
377
|
-
}), 'dsh-kanban:
|
|
381
|
+
}), 'dsh-kanban: session events')
|
|
378
382
|
|
|
379
383
|
// Фоновый шедулер cron для задач
|
|
380
384
|
ctx.effect(() => {
|
|
@@ -405,7 +409,7 @@ export function apply(ctx, config) {
|
|
|
405
409
|
fromCol: task.column,
|
|
406
410
|
toCol: task.column,
|
|
407
411
|
source: 'tool',
|
|
408
|
-
detail: 'cron:
|
|
412
|
+
detail: 'cron: run rejected (elevated permission required)',
|
|
409
413
|
})
|
|
410
414
|
continue
|
|
411
415
|
}
|
|
@@ -434,7 +438,7 @@ export function apply(ctx, config) {
|
|
|
434
438
|
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
435
439
|
}
|
|
436
440
|
} catch (err) {
|
|
437
|
-
ctx.logger?.warn?.(`dsh-kanban:
|
|
441
|
+
ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
|
|
438
442
|
}
|
|
439
443
|
}, 30_000)
|
|
440
444
|
if (typeof cronTimer.unref === 'function') cronTimer.unref()
|
|
@@ -442,7 +446,7 @@ export function apply(ctx, config) {
|
|
|
442
446
|
armCron()
|
|
443
447
|
rearmers.add(armCron)
|
|
444
448
|
return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
|
|
445
|
-
}, 'dsh-kanban:
|
|
449
|
+
}, 'dsh-kanban: cron scheduler')
|
|
446
450
|
|
|
447
451
|
// Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
|
|
448
452
|
// перекрытие запусков не допускается: медленный инстанс иначе копил бы
|
|
@@ -512,7 +516,7 @@ export function apply(ctx, config) {
|
|
|
512
516
|
} catch (error) {
|
|
513
517
|
// Сверка не начиналась вовсе — тоже беда, и своя: так выглядит
|
|
514
518
|
// неразрешённый токен или отсутствующий клиент Gitea.
|
|
515
|
-
syncState.failedToStart('
|
|
519
|
+
syncState.failedToStart('sync', error?.message)
|
|
516
520
|
throw error
|
|
517
521
|
} finally {
|
|
518
522
|
syncing = false
|
|
@@ -532,7 +536,7 @@ export function apply(ctx, config) {
|
|
|
532
536
|
arm()
|
|
533
537
|
rearmers.add(arm)
|
|
534
538
|
return () => { rearmers.delete(arm); clearInterval(timer) }
|
|
535
|
-
}, 'dsh-kanban:
|
|
539
|
+
}, 'dsh-kanban: sync timer')
|
|
536
540
|
|
|
537
541
|
ctx.effect(() => ctx.webServer.register({
|
|
538
542
|
kind: 'exact',
|
|
@@ -553,7 +557,7 @@ export function apply(ctx, config) {
|
|
|
553
557
|
writeJson(res, error?.status ?? 500, { error: 'bad-request' })
|
|
554
558
|
}
|
|
555
559
|
},
|
|
556
|
-
}), 'dsh-kanban:
|
|
560
|
+
}), 'dsh-kanban: project task')
|
|
557
561
|
|
|
558
562
|
ctx.effect(() => ctx.webServer.register({
|
|
559
563
|
kind: 'exact',
|
|
@@ -573,7 +577,7 @@ export function apply(ctx, config) {
|
|
|
573
577
|
id: row.id, name: row.name || row.id, description: row.description,
|
|
574
578
|
}))
|
|
575
579
|
} catch (error) {
|
|
576
|
-
ctx.logger?.warn?.(`dsh-kanban:
|
|
580
|
+
ctx.logger?.warn?.(`dsh-kanban: agent profiles failed to load: ${error?.message}`)
|
|
577
581
|
}
|
|
578
582
|
let access = []
|
|
579
583
|
let accessDefault = ''
|
|
@@ -583,7 +587,7 @@ export function apply(ctx, config) {
|
|
|
583
587
|
accessDefault = permissions.defaultPreset ?? ''
|
|
584
588
|
}
|
|
585
589
|
} catch (error) {
|
|
586
|
-
ctx.logger?.warn?.(`dsh-kanban:
|
|
590
|
+
ctx.logger?.warn?.(`dsh-kanban: access levels failed to load: ${error?.message}`)
|
|
587
591
|
}
|
|
588
592
|
reply(res, {
|
|
589
593
|
agentPresets,
|
|
@@ -592,13 +596,13 @@ export function apply(ctx, config) {
|
|
|
592
596
|
accessDefault,
|
|
593
597
|
})
|
|
594
598
|
},
|
|
595
|
-
}), 'dsh-kanban:
|
|
599
|
+
}), 'dsh-kanban: agent profiles and auth scopes')
|
|
596
600
|
|
|
597
601
|
ctx.effect(() => ctx.webServer.register({
|
|
598
602
|
kind: 'exact',
|
|
599
603
|
path: '/dsh-kanban/sessions',
|
|
600
604
|
handler: (req, res) => { reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) }) },
|
|
601
|
-
}), 'dsh-kanban:
|
|
605
|
+
}), 'dsh-kanban: live sessions')
|
|
602
606
|
|
|
603
607
|
ctx.effect(() => ctx.webServer.register({
|
|
604
608
|
kind: 'exact',
|
|
@@ -630,13 +634,13 @@ export function apply(ctx, config) {
|
|
|
630
634
|
writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
|
|
631
635
|
}
|
|
632
636
|
},
|
|
633
|
-
}), 'dsh-kanban:
|
|
637
|
+
}), 'dsh-kanban: batch dispatch')
|
|
634
638
|
|
|
635
639
|
ctx.effect(() => ctx.webServer.register({
|
|
636
640
|
kind: 'exact',
|
|
637
641
|
path: '/dsh-kanban/archive',
|
|
638
642
|
handler: (req, res) => { reply(res, listArchive({ store })) },
|
|
639
|
-
}), 'dsh-kanban:
|
|
643
|
+
}), 'dsh-kanban: archive')
|
|
640
644
|
|
|
641
645
|
ctx.effect(() => ctx.webServer.register({
|
|
642
646
|
kind: 'exact',
|
|
@@ -650,7 +654,7 @@ export function apply(ctx, config) {
|
|
|
650
654
|
writeJson(res, 502, { error: 'sync-failed' })
|
|
651
655
|
}
|
|
652
656
|
},
|
|
653
|
-
}), 'dsh-kanban:
|
|
657
|
+
}), 'dsh-kanban: manual sync')
|
|
654
658
|
|
|
655
659
|
ctx.effect(() => ctx.webServer.register({
|
|
656
660
|
kind: 'exact',
|
|
@@ -702,7 +706,7 @@ export function apply(ctx, config) {
|
|
|
702
706
|
writeJson(res, error?.status ?? 500, { error: 'webhook-failed' })
|
|
703
707
|
}
|
|
704
708
|
},
|
|
705
|
-
}), 'dsh-kanban:
|
|
709
|
+
}), 'dsh-kanban: gitea webhook')
|
|
706
710
|
|
|
707
711
|
// Инструмент для агента. По умолчанию ВЫКЛЮЧЕН: скилл воркфлоу пока
|
|
708
712
|
// запрещает этому CLI трогать канбан, и включить инструмент раньше правки
|
|
@@ -735,7 +739,7 @@ export function apply(ctx, config) {
|
|
|
735
739
|
arm()
|
|
736
740
|
rearmers.add(arm)
|
|
737
741
|
return () => { rearmers.delete(arm); unregister?.() }
|
|
738
|
-
}, 'dsh-kanban:
|
|
742
|
+
}, 'dsh-kanban: board tool')
|
|
739
743
|
|
|
740
744
|
/**
|
|
741
745
|
* Состояние живого агента сессии либо `undefined`, если агента нет.
|
|
@@ -761,7 +765,7 @@ export function apply(ctx, config) {
|
|
|
761
765
|
projectRoot: rootOf(getConfig()),
|
|
762
766
|
}))
|
|
763
767
|
},
|
|
764
|
-
}), 'dsh-kanban:
|
|
768
|
+
}), 'dsh-kanban: board route')
|
|
765
769
|
|
|
766
770
|
// Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
|
|
767
771
|
//
|
|
@@ -800,7 +804,7 @@ export function apply(ctx, config) {
|
|
|
800
804
|
staleDays: Number.isFinite(days) && days > 0 ? days : 14,
|
|
801
805
|
}))
|
|
802
806
|
},
|
|
803
|
-
}), 'dsh-kanban:
|
|
807
|
+
}), 'dsh-kanban: board metrics')
|
|
804
808
|
|
|
805
809
|
ctx.effect(() => ctx.webServer.register({
|
|
806
810
|
kind: 'exact',
|
|
@@ -808,7 +812,7 @@ export function apply(ctx, config) {
|
|
|
808
812
|
// Пустой логин — законный ответ: Gitea может быть не настроен или не
|
|
809
813
|
// отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
|
|
810
814
|
handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
|
|
811
|
-
}), 'dsh-kanban:
|
|
815
|
+
}), 'dsh-kanban: whoami')
|
|
812
816
|
|
|
813
817
|
ctx.effect(() => ctx.webServer.register({
|
|
814
818
|
kind: 'exact',
|
|
@@ -824,7 +828,7 @@ export function apply(ctx, config) {
|
|
|
824
828
|
})
|
|
825
829
|
res.end(csv)
|
|
826
830
|
},
|
|
827
|
-
}), 'dsh-kanban:
|
|
831
|
+
}), 'dsh-kanban: csv export')
|
|
828
832
|
|
|
829
833
|
ctx.effect(() => ctx.webServer.register({
|
|
830
834
|
kind: 'exact',
|
|
@@ -832,7 +836,7 @@ export function apply(ctx, config) {
|
|
|
832
836
|
handler: (req, res) => {
|
|
833
837
|
eventHub.handleSseRequest(req, res)
|
|
834
838
|
},
|
|
835
|
-
}), 'dsh-kanban:
|
|
839
|
+
}), 'dsh-kanban: sse events stream')
|
|
836
840
|
|
|
837
841
|
ctx.effect(() => ctx.webServer.register({
|
|
838
842
|
kind: 'exact',
|
|
@@ -840,7 +844,7 @@ export function apply(ctx, config) {
|
|
|
840
844
|
handler: (req, res) => {
|
|
841
845
|
reply(res, { templates: TASK_TEMPLATES })
|
|
842
846
|
},
|
|
843
|
-
}), 'dsh-kanban:
|
|
847
|
+
}), 'dsh-kanban: task templates')
|
|
844
848
|
|
|
845
849
|
ctx.effect(() => ctx.webServer.register({
|
|
846
850
|
kind: 'exact',
|
|
@@ -856,7 +860,7 @@ export function apply(ctx, config) {
|
|
|
856
860
|
writeJson(res, 400, { error: 'bad-request' })
|
|
857
861
|
}
|
|
858
862
|
},
|
|
859
|
-
}), 'dsh-kanban:
|
|
863
|
+
}), 'dsh-kanban: board snapshot')
|
|
860
864
|
|
|
861
865
|
ctx.effect(() => ctx.webServer.register({
|
|
862
866
|
// Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
|
|
@@ -942,7 +946,7 @@ export function apply(ctx, config) {
|
|
|
942
946
|
syncPower()
|
|
943
947
|
reply(res, started)
|
|
944
948
|
} catch (error) {
|
|
945
|
-
ctx.logger?.warn?.(`dsh-kanban:
|
|
949
|
+
ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
|
|
946
950
|
// Отказ по уровню доступа называется своим именем: «не удалось» на
|
|
947
951
|
// месте «сессия пошла бы с большими правами» — это молчание.
|
|
948
952
|
writeJson(res, error?.status ?? 500, { error: error?.key ?? 'start-failed' })
|
|
@@ -1192,7 +1196,7 @@ export function apply(ctx, config) {
|
|
|
1192
1196
|
writeJson(res, error?.status ?? 500, { error: 'bad-request' })
|
|
1193
1197
|
}
|
|
1194
1198
|
},
|
|
1195
|
-
}), 'dsh-kanban:
|
|
1199
|
+
}), 'dsh-kanban: task routes')
|
|
1196
1200
|
|
|
1197
1201
|
// Готовность Gitea проверяется на каждом запросе, а не при загрузке: адрес и
|
|
1198
1202
|
// учётная запись правятся в карточке настроек на ходу, и маршруты обязаны
|
|
@@ -1204,7 +1208,7 @@ export function apply(ctx, config) {
|
|
|
1204
1208
|
const url = urlOf(req)
|
|
1205
1209
|
reply(res, await searchRepos({ gitea, query: url.searchParams.get('q') || '' }))
|
|
1206
1210
|
},
|
|
1207
|
-
}), 'dsh-kanban:
|
|
1211
|
+
}), 'dsh-kanban: repo search')
|
|
1208
1212
|
|
|
1209
1213
|
ctx.effect(() => ctx.webServer.register({
|
|
1210
1214
|
kind: 'exact',
|
|
@@ -1219,7 +1223,7 @@ export function apply(ctx, config) {
|
|
|
1219
1223
|
board: url.searchParams.get('board') || 'main',
|
|
1220
1224
|
}))
|
|
1221
1225
|
},
|
|
1222
|
-
}), 'dsh-kanban:
|
|
1226
|
+
}), 'dsh-kanban: importable issues')
|
|
1223
1227
|
|
|
1224
1228
|
ctx.effect(() => ctx.webServer.register({
|
|
1225
1229
|
kind: 'exact',
|
|
@@ -1238,7 +1242,7 @@ export function apply(ctx, config) {
|
|
|
1238
1242
|
writeJson(res, error?.status ?? 500, { error: 'bad-request' })
|
|
1239
1243
|
}
|
|
1240
1244
|
},
|
|
1241
|
-
}), 'dsh-kanban:
|
|
1245
|
+
}), 'dsh-kanban: issue import')
|
|
1242
1246
|
|
|
1243
1247
|
// Провайдеры отдаются сразу, модели — только для запрошенного провайдера:
|
|
1244
1248
|
// перечень моделей провайдер отдаёт по сети, и опрашивать все разом ради
|
|
@@ -1269,7 +1273,7 @@ export function apply(ctx, config) {
|
|
|
1269
1273
|
writeJson(res, 200, { current, providers, models: [] })
|
|
1270
1274
|
}
|
|
1271
1275
|
},
|
|
1272
|
-
}), 'dsh-kanban:
|
|
1276
|
+
}), 'dsh-kanban: providers and models')
|
|
1273
1277
|
|
|
1274
1278
|
ctx.effect(() => ctx.webServer.register({
|
|
1275
1279
|
kind: 'prefix',
|
|
@@ -1279,5 +1283,12 @@ export function apply(ctx, config) {
|
|
|
1279
1283
|
if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
|
|
1280
1284
|
reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]) }))
|
|
1281
1285
|
},
|
|
1282
|
-
}), 'dsh-kanban:
|
|
1286
|
+
}), 'dsh-kanban: task route by session')
|
|
1287
|
+
|
|
1288
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
1289
|
+
endpoint: '/api/dsh-kanban/update',
|
|
1290
|
+
packageName: '@goodandready/dsh-kanban',
|
|
1291
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
1292
|
+
}), 'dsh-kanban: plugin updater route')
|
|
1293
|
+
|
|
1283
1294
|
}
|
package/lib/power.js
CHANGED
package/lib/progress-dump.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
|
|
2
2
|
// Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const BEGIN_MARKER = '<<<PROGRESSDUMP'
|
|
5
|
+
const END_MARKER = '>>>PROGRESSDUMP'
|
|
6
|
+
const FIELD_BYTE_LIMIT = 8 * 1024
|
|
7
7
|
export const REDACTED = '[REDACTED]'
|
|
8
8
|
|
|
9
9
|
const SENSITIVE_PATTERNS = [
|
package/lib/routes.js
CHANGED
|
@@ -19,12 +19,54 @@ import { computeNextRun } from './cron.js'
|
|
|
19
19
|
/** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
|
|
20
20
|
export const MAX_BODY_BYTES = 256 * 1024
|
|
21
21
|
|
|
22
|
+
function isLoopbackAddress(addr) {
|
|
23
|
+
if (!addr) return true
|
|
24
|
+
return addr === '127.0.0.1' ||
|
|
25
|
+
addr === '::1' ||
|
|
26
|
+
addr === '::ffff:127.0.0.1' ||
|
|
27
|
+
addr === 'localhost'
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* Запрос с чужого сайта. Изменяющие маршруты правят локальное состояние, и
|
|
24
|
-
* пускать в них кросс-сайтовый запрос
|
|
32
|
+
* пускать в них кросс-сайтовый запрос нельзя (#261).
|
|
33
|
+
* Защита требует:
|
|
34
|
+
* 1. Наличие безопасного sec-fetch-site ('same-origin', 'same-site', 'none').
|
|
35
|
+
* 2. Совпадение origin и host (если origin передан).
|
|
36
|
+
* 3. Loopback-источник (remoteAddress: 127.0.0.1, ::1, ::ffff:127.0.0.1).
|
|
25
37
|
*/
|
|
26
38
|
export function isTrustedRequest(req) {
|
|
27
|
-
|
|
39
|
+
if (!req || !req.headers) return false
|
|
40
|
+
|
|
41
|
+
// 1. Проверка sec-fetch-site (отсутствие или cross-site запрещены)
|
|
42
|
+
const site = req.headers['sec-fetch-site']
|
|
43
|
+
if (!site || site === 'cross-site') {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
if (site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Сверка origin с host
|
|
51
|
+
const origin = req.headers.origin
|
|
52
|
+
if (origin !== undefined) {
|
|
53
|
+
if (origin === 'null') return false
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(origin)
|
|
56
|
+
const host = req.headers.host || ''
|
|
57
|
+
if (parsed.host && parsed.host !== host) return false
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Проверка loopback remoteAddress
|
|
64
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
|
|
65
|
+
if (remote && !isLoopbackAddress(remote)) {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true
|
|
28
70
|
}
|
|
29
71
|
|
|
30
72
|
/** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
|
|
@@ -232,7 +274,7 @@ export function confirmPermission({ store, id }) {
|
|
|
232
274
|
}
|
|
233
275
|
|
|
234
276
|
/** Известные приоритеты. Пустой — «без приоритета». */
|
|
235
|
-
|
|
277
|
+
const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
236
278
|
|
|
237
279
|
/**
|
|
238
280
|
* Дописать заметку в тело задачи.
|
|
@@ -392,7 +434,7 @@ export function importBoard({ store, input }) {
|
|
|
392
434
|
if (typeof store.backupDatabase === 'function') {
|
|
393
435
|
try {
|
|
394
436
|
backupMeta = store.backupDatabase()
|
|
395
|
-
} catch {}
|
|
437
|
+
} catch { /* backup best effort */ }
|
|
396
438
|
}
|
|
397
439
|
if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
|
|
398
440
|
if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
|
|
@@ -684,7 +726,7 @@ export async function handleTriageTask({ store, gitea, task, input }) {
|
|
|
684
726
|
issueNumber = giteaIssue.number
|
|
685
727
|
issueUrl = giteaIssue.html_url || null
|
|
686
728
|
}
|
|
687
|
-
} catch {}
|
|
729
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
688
730
|
}
|
|
689
731
|
|
|
690
732
|
const sub = store.createTask({
|
|
@@ -760,7 +802,7 @@ export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
|
760
802
|
issueNumber = giteaIssue.number
|
|
761
803
|
issueUrl = giteaIssue.html_url || null
|
|
762
804
|
}
|
|
763
|
-
} catch {}
|
|
805
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
764
806
|
}
|
|
765
807
|
|
|
766
808
|
store.updateTask(task.id, { isEpic: true })
|
package/lib/store.js
CHANGED
|
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
37
37
|
repo TEXT,
|
|
38
38
|
issueNumber INTEGER,
|
|
39
39
|
issueUrl TEXT,
|
|
40
|
+
externalRef TEXT,
|
|
40
41
|
labels TEXT NOT NULL DEFAULT '[]',
|
|
41
42
|
branch TEXT,
|
|
42
43
|
worktree TEXT,
|
|
@@ -97,7 +98,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
97
98
|
|
|
98
99
|
/** Поля задачи, которые разрешено править через updateTask. */
|
|
99
100
|
const WRITABLE = [
|
|
100
|
-
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
101
|
+
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'externalRef', 'labels',
|
|
101
102
|
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
102
103
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
103
104
|
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
@@ -168,6 +169,8 @@ export function openStore({ dir }) {
|
|
|
168
169
|
const db = new DatabaseSync(join(dir, 'kanban.db'))
|
|
169
170
|
db.exec('PRAGMA journal_mode = WAL')
|
|
170
171
|
db.exec('PRAGMA foreign_keys = ON')
|
|
172
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
173
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
171
174
|
db.exec(SCHEMA)
|
|
172
175
|
// База могла быть создана прежней версией. Добавляем недостающие столбцы
|
|
173
176
|
// поштучно: ALTER на существующий столбец бросает, и это штатный ответ
|
|
@@ -201,6 +204,7 @@ export function openStore({ dir }) {
|
|
|
201
204
|
['rejectedAt', "INTEGER NOT NULL DEFAULT 0"],
|
|
202
205
|
['parentId', "TEXT NOT NULL DEFAULT ''"],
|
|
203
206
|
['isEpic', "INTEGER NOT NULL DEFAULT 0"],
|
|
207
|
+
['externalRef', "TEXT"],
|
|
204
208
|
]) {
|
|
205
209
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
206
210
|
}
|
|
@@ -212,11 +216,12 @@ export function openStore({ dir }) {
|
|
|
212
216
|
// Индексы для ускорения выборок активных задач, поиска по Gitea issue и cron-задач
|
|
213
217
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
|
|
214
218
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
|
|
219
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_external_ref ON tasks(externalRef)')
|
|
215
220
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_cron_next ON tasks(archivedAt, nextRunAt)')
|
|
216
221
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_queued ON tasks(archivedAt, queuedAt)')
|
|
217
222
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
|
|
218
223
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
219
|
-
try { db.exec('PRAGMA optimize') } catch {}
|
|
224
|
+
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
220
225
|
|
|
221
226
|
const q = {
|
|
222
227
|
boards: db.prepare('SELECT * FROM boards ORDER BY position'),
|
|
@@ -233,6 +238,7 @@ export function openStore({ dir }) {
|
|
|
233
238
|
dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
|
|
234
239
|
isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
235
240
|
byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
241
|
+
byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
|
|
236
242
|
tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
|
|
237
243
|
lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
|
|
238
244
|
deleteTask: db.prepare('DELETE FROM tasks WHERE id = ?'),
|
|
@@ -298,10 +304,10 @@ export function openStore({ dir }) {
|
|
|
298
304
|
.sort((a, b) => b.time - a.time)
|
|
299
305
|
if (files.length > 10) {
|
|
300
306
|
for (const old of files.slice(10)) {
|
|
301
|
-
try { unlinkSync(old.path) } catch {}
|
|
307
|
+
try { unlinkSync(old.path) } catch { /* unlink old backup best-effort */ }
|
|
302
308
|
}
|
|
303
309
|
}
|
|
304
|
-
} catch {}
|
|
310
|
+
} catch { /* backup rotation best-effort */ }
|
|
305
311
|
return { backup: backupName, path: dest }
|
|
306
312
|
},
|
|
307
313
|
|
|
@@ -373,16 +379,16 @@ export function openStore({ dir }) {
|
|
|
373
379
|
const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
|
|
374
380
|
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
375
381
|
db.prepare(`INSERT INTO tasks
|
|
376
|
-
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
382
|
+
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
|
|
377
383
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
378
384
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
379
385
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
|
|
380
386
|
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
|
|
381
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
382
388
|
id, board, column, position,
|
|
383
389
|
input.title ?? '', input.body ?? '',
|
|
384
390
|
input.owner ?? null, input.repo ?? null,
|
|
385
|
-
input.issueNumber ?? null, input.issueUrl ?? null,
|
|
391
|
+
input.issueNumber ?? null, input.issueUrl ?? null, input.externalRef ?? null,
|
|
386
392
|
JSON.stringify(input.labels ?? []),
|
|
387
393
|
input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
|
|
388
394
|
input.sessionId ?? null, input.model ?? null, input.provider ?? null,
|
|
@@ -569,6 +575,12 @@ export function openStore({ dir }) {
|
|
|
569
575
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
570
576
|
},
|
|
571
577
|
|
|
578
|
+
findTaskByExternalRef(externalRef) {
|
|
579
|
+
const key = typeof externalRef === 'string' ? externalRef.trim() : ''
|
|
580
|
+
if (!key) return undefined
|
|
581
|
+
return rowToTask(q.byExternalRef.get(key))
|
|
582
|
+
},
|
|
583
|
+
|
|
572
584
|
listSubtasks(parentId) {
|
|
573
585
|
if (!parentId) return []
|
|
574
586
|
return q.subtasksOf.all(parentId).map(rowToTask)
|
package/lib/sync.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
|
|
|
45
45
|
* завершённой. Поэтому репозиторий с неудачным чтением пропускается до
|
|
46
46
|
* следующей сверки целиком.
|
|
47
47
|
*/
|
|
48
|
-
|
|
48
|
+
async function readRepo({ gitea, owner, repo }) {
|
|
49
49
|
const [pulls, branches] = await Promise.all([
|
|
50
50
|
gitea.listPulls({ owner, repo, state: 'all' }),
|
|
51
51
|
gitea.listBranches({ owner, repo }),
|
|
@@ -267,7 +267,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
267
267
|
*
|
|
268
268
|
* @returns {Promise<Array<{owner: string, repo: string}>>}
|
|
269
269
|
*/
|
|
270
|
-
|
|
270
|
+
async function resolveRepos({ gitea, config, owner }) {
|
|
271
271
|
const named = watchedRepos({ config, owner })
|
|
272
272
|
if (named.length > 0) return named
|
|
273
273
|
if (owner === undefined || owner === '') {
|