@goodandready/dsh-kanban 0.1.31 → 0.1.33
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/docs/design/DESIGN.md +12 -1
- package/docs/plans/2026-09-08-concurrency-safety.md +8 -0
- package/docs/plans/2026-09-08-diff-viewer-merge.md +7 -0
- package/lib/board-tool.js +10 -1
- package/lib/client.js +93 -0
- package/lib/config.js +2 -0
- package/lib/index.js +55 -4
- package/lib/launcher.js +70 -0
- package/lib/routes.js +7 -1
- package/lib/store.js +32 -1
- package/lib/worktree.js +159 -1
- package/package.json +1 -1
package/docs/design/DESIGN.md
CHANGED
|
@@ -64,4 +64,15 @@
|
|
|
64
64
|
- Привязка проекта сессии: при создании агента с воркдеревом поле meta.cwd сохраняет канонический projectRoot (repoDir), предотвращая выпадение сессий в категорию «Ungrouped» в сайдбаре DSH; путь воркдерева передается в meta.worktreePath и инструкциях агента (#227).
|
|
65
65
|
- Режим «↻ Продолжить» (Resume Execution): эндпоинт POST /dsh-kanban/tasks/:id/resume и кнопка в TaskDrawer; сохраняет существующее воркдерево, ветку и незакоммиченные файлы, передавая агенту преамбулу продолжения и актуальный PROGRESSDUMP (#226).
|
|
66
66
|
- Фильтрация structural noise в проверках dirtyFiles: в checkWorktreeDirty добавлен флаг --ignore-submodules=all, исключающий ложные блокировки удаления воркдеревьев из-за субмодулей и дрейфа gitlinks (#213).
|
|
67
|
-
- Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
|
|
67
|
+
- Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
|
|
68
|
+
## Locked Decisions
|
|
69
|
+
|
|
70
|
+
### 2026-09-08: Релиз Vector 3 (Просмотр Git Diff / Коммитов и Однокликовый Merge) (#211, #212)
|
|
71
|
+
- **Git Diff & Commits Viewer**: В `TaskDrawer` интегрирован блок просмотра истории коммитов сессии и unified diff измененных файлов воркдерева (`GET /tasks/:id/diff` и `GET /tasks/:id/commits`). Diff безопасно ограничен 250 КБ.
|
|
72
|
+
- **One-Click Merge (--no-ff)**: В секции Review Acceptance Gate добавлена кнопка «✓ Принять и смержить» (`POST /tasks/:id/merge`). Выполняется проверка на отсутствие незакоммиченных изменений (`checkWorktreeDirty`), слияние `git merge --no-ff` в целевую ветку (`main`), автоматическая дерегистрация и удаление воркдерева с переводом задачи в `done`.
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
### 2026-09-08: Релиз Vector 2 (Управление параллелизмом и безопасность) (#214, #216, #224)
|
|
76
|
+
- **maxConcurrentSessions**: Лимит параллельных сессий агентов (по умолчанию 3) предотвращает исчерпание памяти/CPU на хосте. Задачи сверх лимита переводятся в очередь (`queuedAt > 0`) и автоматически запускаются диспетчером при завершении активных сессий.
|
|
77
|
+
- **workspaceClaimBoundaries**: Инструмент `board_move` проверяет принадлежность задачи к воркспейсу текущей сессии агента, предотвращая случайный межпроектный захват карточек.
|
|
78
|
+
- **Страховочный бэкап при импорте снимка**: `importBoard` в `/dsh-kanban/snapshot` автоматически делает снимок базы SQLite в `backups/kanban-backup-<timestamp>.db` с ротацией последних 10 копий перед перезаписью данных.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Implementation Plan: Vector 2 — Concurrency & Safety (#214, #216, #224)
|
|
2
|
+
|
|
3
|
+
Implementation of resource protection, workspace claim boundaries, and database backup before snapshot import in `@goodandready/dsh-kanban` (v0.1.32).
|
|
4
|
+
|
|
5
|
+
## Issues Scope
|
|
6
|
+
1. **#214**: Global concurrent session limit and task queue (`maxConcurrentSessions`).
|
|
7
|
+
2. **#216**: Project workspace claim boundaries (`workspaceClaimBoundaries`).
|
|
8
|
+
3. **#224**: Automatic database safety backup before board snapshot import.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Implementation Plan: Vector 3 — Git Diff / Commit Viewer & One-Click Merge (#211, #212)
|
|
2
|
+
|
|
3
|
+
Implementation of in-board git diff viewer and one-click worktree merge in `@goodandready/dsh-kanban` (v0.1.33).
|
|
4
|
+
|
|
5
|
+
## Issues Scope
|
|
6
|
+
1. **#211**: In-drawer Git Diff and commits viewer for human acceptance.
|
|
7
|
+
2. **#212**: One-click worktree branch merge upon acceptance (`--no-ff`) with automated cleanup.
|
package/lib/board-tool.js
CHANGED
|
@@ -80,7 +80,7 @@ function nameOf(task) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** Определение board_move, готовое к передаче в `defineTool`. */
|
|
83
|
-
export function boardMoveDefinition({ store }) {
|
|
83
|
+
export function boardMoveDefinition({ store, config }) {
|
|
84
84
|
return {
|
|
85
85
|
name: 'board_move',
|
|
86
86
|
description: 'Move the kanban card of the current task to another column. '
|
|
@@ -109,6 +109,15 @@ export function boardMoveDefinition({ store }) {
|
|
|
109
109
|
const picked = pickTask(store, exec, ref)
|
|
110
110
|
if (picked.error !== undefined) return picked.error
|
|
111
111
|
const task = picked.task
|
|
112
|
+
|
|
113
|
+
// Проверка границ владения воркспейсом (#216)
|
|
114
|
+
const claimBoundaries = typeof config === 'function' ? config()?.workspaceClaimBoundaries : config?.workspaceClaimBoundaries
|
|
115
|
+
if (claimBoundaries !== false && typeof task.workspaceId === 'string' && task.workspaceId.trim() !== '') {
|
|
116
|
+
const sessionWs = String(exec?.agent?.session?.meta?.workspaceId ?? exec?.session?.meta?.workspaceId ?? '').trim()
|
|
117
|
+
if (sessionWs !== '' && sessionWs !== task.workspaceId.trim()) {
|
|
118
|
+
return `Cannot move card: task belongs to workspace "${task.workspaceId}", but current session belongs to workspace "${sessionWs}". Cross-workspace moves are restricted by workspaceClaimBoundaries.`
|
|
119
|
+
}
|
|
120
|
+
}
|
|
112
121
|
// Перечисление уже не предлагает `done`, но модель может прислать его
|
|
113
122
|
// мимо схемы, а завершение задачи не должно опираться на заявление.
|
|
114
123
|
if (column === 'done' || TOOL_FORBIDDEN_COLUMNS.includes(column)) {
|
package/lib/client.js
CHANGED
|
@@ -873,6 +873,8 @@ window.__ModuleLoader__.load({
|
|
|
873
873
|
'field.sessionDefaultPermission': 'Default permission baseline',
|
|
874
874
|
'hint.sessionDefaultPermission': 'Tasks requesting permissions above this rank require confirmation.',
|
|
875
875
|
'field.announceToAgent': 'Announce board in system prompt',
|
|
876
|
+
'field.maxConcurrentSessions': 'Max concurrent agent sessions',
|
|
877
|
+
'field.workspaceClaimBoundaries': 'Workspace claim boundaries',
|
|
876
878
|
'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
|
|
877
879
|
'task.runs': 'Execution History',
|
|
878
880
|
'task.noRuns': 'No runs yet',
|
|
@@ -1132,6 +1134,15 @@ window.__ModuleLoader__.load({
|
|
|
1132
1134
|
'report.checksRun': 'Verification checks',
|
|
1133
1135
|
'report.artifacts': 'Artifacts',
|
|
1134
1136
|
'report.risks': 'Risks & notes',
|
|
1137
|
+
'diff.title': 'Git Changes & Diff',
|
|
1138
|
+
'diff.empty': 'No changes detected in worktree.',
|
|
1139
|
+
'diff.commits': 'Commits ({n})',
|
|
1140
|
+
'diff.filesChanged': '{n} files changed',
|
|
1141
|
+
'diff.truncated': 'Diff truncated (exceeded limit)',
|
|
1142
|
+
'diff.show': 'Show diff',
|
|
1143
|
+
'diff.hide': 'Hide diff',
|
|
1144
|
+
'acceptance.acceptAndMerge': 'Accept & Merge (--no-ff)',
|
|
1145
|
+
'acceptance.merging': 'Merging...',
|
|
1135
1146
|
'acceptance.title': 'Review Acceptance Gate',
|
|
1136
1147
|
'acceptance.accept': 'Accept (Done)',
|
|
1137
1148
|
'acceptance.reject': 'Send back for rework',
|
|
@@ -1195,6 +1206,8 @@ window.__ModuleLoader__.load({
|
|
|
1195
1206
|
'field.sessionDefaultPermission': '基线默认权限',
|
|
1196
1207
|
'hint.sessionDefaultPermission': '超出此权限的任务需要点击人工确认。',
|
|
1197
1208
|
'field.announceToAgent': '在系统提示词中声明看板',
|
|
1209
|
+
'field.maxConcurrentSessions': '最大并发会话数',
|
|
1210
|
+
'field.workspaceClaimBoundaries': '工作区认领边界',
|
|
1198
1211
|
'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
|
|
1199
1212
|
'task.runs': '执行历史',
|
|
1200
1213
|
'task.noRuns': '暂无执行记录',
|
|
@@ -1454,6 +1467,15 @@ window.__ModuleLoader__.load({
|
|
|
1454
1467
|
'report.checksRun': '验证检查',
|
|
1455
1468
|
'report.artifacts': '生成产物',
|
|
1456
1469
|
'report.risks': '风险与说明',
|
|
1470
|
+
'diff.title': 'Git 变更与 Diff',
|
|
1471
|
+
'diff.empty': '工作区未检测到变更。',
|
|
1472
|
+
'diff.commits': '提交历史 ({n})',
|
|
1473
|
+
'diff.filesChanged': '{n} 个文件变更',
|
|
1474
|
+
'diff.truncated': 'Diff 超限已截断',
|
|
1475
|
+
'diff.show': '查看 diff',
|
|
1476
|
+
'diff.hide': '收起 diff',
|
|
1477
|
+
'acceptance.acceptAndMerge': '验收并合并 (--no-ff)',
|
|
1478
|
+
'acceptance.merging': '合并中...',
|
|
1457
1479
|
'acceptance.title': '评审验收网关',
|
|
1458
1480
|
'acceptance.accept': '验收通过 (完成)',
|
|
1459
1481
|
'acceptance.reject': '退回重做',
|
|
@@ -1513,6 +1535,8 @@ window.__ModuleLoader__.load({
|
|
|
1513
1535
|
{ field: 'permissionGateEnabled', kind: 'bool', group: 'general' },
|
|
1514
1536
|
{ field: 'sessionDefaultPermission', kind: 'text', group: 'general' },
|
|
1515
1537
|
{ field: 'announceToAgent', kind: 'bool', group: 'general' },
|
|
1538
|
+
{ field: 'maxConcurrentSessions', kind: 'number', group: 'limits' },
|
|
1539
|
+
{ field: 'workspaceClaimBoundaries', kind: 'bool', group: 'general' },
|
|
1516
1540
|
]
|
|
1517
1541
|
|
|
1518
1542
|
function hintKey(spec) {
|
|
@@ -2845,6 +2869,59 @@ window.__ModuleLoader__.load({
|
|
|
2845
2869
|
}, '⚠️ ' + t('acceptance.rejectedAlert', { reason: openTask.rejectReason })),
|
|
2846
2870
|
)
|
|
2847
2871
|
: null,
|
|
2872
|
+
openTask.worktree
|
|
2873
|
+
? React.createElement('div', {
|
|
2874
|
+
style: {
|
|
2875
|
+
margin: '8px 0', padding: 12,
|
|
2876
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
2877
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
2878
|
+
borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 8,
|
|
2879
|
+
},
|
|
2880
|
+
},
|
|
2881
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
2882
|
+
React.createElement('div', { className: 'dkb-group', style: { margin: 0 } },
|
|
2883
|
+
t('diff.title'),
|
|
2884
|
+
commitsData.length > 0 ? ` · ${t('diff.commits', { n: commitsData.length })}` : '',
|
|
2885
|
+
diffData?.filesChanged ? ` · ${t('diff.filesChanged', { n: diffData.filesChanged })}` : '',
|
|
2886
|
+
),
|
|
2887
|
+
React.createElement('button', {
|
|
2888
|
+
type: 'button', className: 'dkb-discard',
|
|
2889
|
+
style: { padding: '2px 8px', fontSize: 11 },
|
|
2890
|
+
onClick: () => {
|
|
2891
|
+
if (!diffData && !diffExpanded) loadDiffAndCommits()
|
|
2892
|
+
setDiffExpanded(!diffExpanded)
|
|
2893
|
+
},
|
|
2894
|
+
}, diffExpanded ? t('diff.hide') : t('diff.show')),
|
|
2895
|
+
),
|
|
2896
|
+
diffExpanded ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
2897
|
+
commitsData.length > 0
|
|
2898
|
+
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 4 } },
|
|
2899
|
+
commitsData.map((cm) => React.createElement('div', {
|
|
2900
|
+
key: cm.hash,
|
|
2901
|
+
style: { fontSize: 11, display: 'flex', gap: 6, opacity: 0.85, fontFamily: 'monospace' },
|
|
2902
|
+
},
|
|
2903
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-link, #3b82f6)' } }, cm.hash),
|
|
2904
|
+
React.createElement('span', { style: { flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, cm.subject),
|
|
2905
|
+
React.createElement('span', { style: { opacity: 0.6 } }, cm.date),
|
|
2906
|
+
))
|
|
2907
|
+
)
|
|
2908
|
+
: null,
|
|
2909
|
+
diffLoading
|
|
2910
|
+
? React.createElement('div', { style: { fontSize: 12, opacity: 0.7 } }, 'Loading diff...')
|
|
2911
|
+
: diffData?.diff
|
|
2912
|
+
? React.createElement('pre', {
|
|
2913
|
+
style: {
|
|
2914
|
+
margin: 0, padding: 8, maxHeight: 280, overflowY: 'auto',
|
|
2915
|
+
background: 'var(--dsw-alias-bg-layer-2, #1e1e1e)',
|
|
2916
|
+
color: 'var(--dsw-alias-label-primary, #ddd)',
|
|
2917
|
+
fontSize: 11, fontFamily: 'monospace', borderRadius: 4, whiteSpace: 'pre-wrap',
|
|
2918
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
2919
|
+
},
|
|
2920
|
+
}, diffData.diff)
|
|
2921
|
+
: React.createElement('div', { style: { fontSize: 12, opacity: 0.7 } }, t('diff.empty'))
|
|
2922
|
+
) : null
|
|
2923
|
+
)
|
|
2924
|
+
: null,
|
|
2848
2925
|
openTask.column === 'review'
|
|
2849
2926
|
? React.createElement('div', {
|
|
2850
2927
|
style: {
|
|
@@ -2856,6 +2933,22 @@ window.__ModuleLoader__.load({
|
|
|
2856
2933
|
},
|
|
2857
2934
|
React.createElement('div', { className: 'dkb-group', style: { margin: 0 } }, t('acceptance.title')),
|
|
2858
2935
|
React.createElement('div', { style: { display: 'flex', gap: 8 } },
|
|
2936
|
+
openTask.worktree
|
|
2937
|
+
? React.createElement('button', {
|
|
2938
|
+
type: 'button', className: 'dkb-save',
|
|
2939
|
+
disabled: merging,
|
|
2940
|
+
style: { background: 'var(--dsw-alias-bg-success, #16a34a)', color: '#fff' },
|
|
2941
|
+
onClick: async () => {
|
|
2942
|
+
try {
|
|
2943
|
+
setMerging(true)
|
|
2944
|
+
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/merge', { method: 'POST', body: '{}' })
|
|
2945
|
+
if (out && out.task) setOpenTask(out.task)
|
|
2946
|
+
await load(board)
|
|
2947
|
+
} catch (e) { setError(t(e.key || e.error || 'error.unknown')) }
|
|
2948
|
+
finally { setMerging(false) }
|
|
2949
|
+
},
|
|
2950
|
+
}, merging ? t('acceptance.merging') : '🔀 ' + t('acceptance.acceptAndMerge'))
|
|
2951
|
+
: null,
|
|
2859
2952
|
React.createElement('button', {
|
|
2860
2953
|
type: 'button', className: 'dkb-save',
|
|
2861
2954
|
onClick: async () => {
|
package/lib/config.js
CHANGED
|
@@ -79,6 +79,8 @@ export const CONFIG_DEFAULTS = {
|
|
|
79
79
|
permissionGateEnabled: false,
|
|
80
80
|
sessionDefaultPermission: 'read-only',
|
|
81
81
|
announceToAgent: true,
|
|
82
|
+
maxConcurrentSessions: 3,
|
|
83
|
+
workspaceClaimBoundaries: true,
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
/** Пояснения к полям; повторно используются схемой и карточкой настроек. */
|
package/lib/index.js
CHANGED
|
@@ -28,10 +28,10 @@ import {
|
|
|
28
28
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
29
29
|
import {
|
|
30
30
|
runTask, resolveModel, buildStartMessage,
|
|
31
|
-
runBatch, queueTask, unqueueTask, liveSessions,
|
|
31
|
+
runBatch, queueTask, unqueueTask, liveSessions, dispatchNextQueuedTask,
|
|
32
32
|
PERMISSION_RANKS,
|
|
33
33
|
} from './launcher.js'
|
|
34
|
-
import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
|
|
34
|
+
import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
|
|
35
35
|
import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
|
|
36
36
|
import { createGiteaClient } from './gitea.js'
|
|
37
37
|
import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
|
|
@@ -265,7 +265,7 @@ export function apply(ctx, config) {
|
|
|
265
265
|
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
266
266
|
try {
|
|
267
267
|
const text = event?.content?.[0]?.text ?? event?.text ?? ''
|
|
268
|
-
handleSessionEvent({
|
|
268
|
+
const res = handleSessionEvent({
|
|
269
269
|
store,
|
|
270
270
|
sessionId: String(session?.id ?? ''),
|
|
271
271
|
session,
|
|
@@ -274,6 +274,20 @@ export function apply(ctx, config) {
|
|
|
274
274
|
error: event?.error,
|
|
275
275
|
})
|
|
276
276
|
syncPower()
|
|
277
|
+
// Если сессия завершила шаг или упала — пробуем продвинуть очередь задач (#214)
|
|
278
|
+
if (event?.type === 'turn/end' || event?.type === 'turn/completed' || event?.type === 'session/error' || event?.type === 'turn/error') {
|
|
279
|
+
dispatchNextQueuedTask({
|
|
280
|
+
agents: ctx.agents,
|
|
281
|
+
store,
|
|
282
|
+
config: getConfig(),
|
|
283
|
+
mintSessionId: () => SessionId(`kanban-queued-${randomUUID()}`),
|
|
284
|
+
createMessage: createUserMessage,
|
|
285
|
+
logger: ctx.logger,
|
|
286
|
+
permissions: ctx.get('permissionPresets'),
|
|
287
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
288
|
+
claimAs: 'queue-dispatcher',
|
|
289
|
+
}).catch(() => {})
|
|
290
|
+
}
|
|
277
291
|
} catch (error) {
|
|
278
292
|
ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
|
|
279
293
|
}
|
|
@@ -623,7 +637,7 @@ export function apply(ctx, config) {
|
|
|
623
637
|
// множить настройки ради разницы, которой человек не почувствует.
|
|
624
638
|
const registerBoardTool = () => {
|
|
625
639
|
const off = [
|
|
626
|
-
ctx.tools.register(defineTool(boardMoveDefinition({ store }))),
|
|
640
|
+
ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
|
|
627
641
|
ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
|
|
628
642
|
ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
|
|
629
643
|
ctx.tools.register(defineTool(boardReportDefinition({ store }))),
|
|
@@ -868,6 +882,43 @@ export function apply(ctx, config) {
|
|
|
868
882
|
reply(res, out)
|
|
869
883
|
return
|
|
870
884
|
}
|
|
885
|
+
if (req.method === 'GET' && action === 'diff') {
|
|
886
|
+
const task = store.getTask(id)
|
|
887
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
888
|
+
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
889
|
+
reply(res, diffResult)
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
if (req.method === 'GET' && action === 'commits') {
|
|
893
|
+
const task = store.getTask(id)
|
|
894
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
895
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
896
|
+
reply(res, { commits })
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
if (req.method === 'POST' && action === 'merge') {
|
|
900
|
+
const task = store.getTask(id)
|
|
901
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
902
|
+
const mergeResult = await mergeTaskWorktree({
|
|
903
|
+
task,
|
|
904
|
+
targetBranch: typeof input?.targetBranch === 'string' ? input.targetBranch.trim() : undefined,
|
|
905
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
906
|
+
})
|
|
907
|
+
if (!mergeResult.success) {
|
|
908
|
+
writeJson(res, 400, { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files })
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
const updated = store.acceptTask(id)
|
|
912
|
+
store.addTransition({
|
|
913
|
+
taskId: id,
|
|
914
|
+
fromCol: task.column,
|
|
915
|
+
toCol: 'done',
|
|
916
|
+
source: 'manual',
|
|
917
|
+
detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
|
|
918
|
+
})
|
|
919
|
+
reply(res, { task: updated, merged: true, targetBranch: mergeResult.targetBranch })
|
|
920
|
+
return
|
|
921
|
+
}
|
|
871
922
|
if (req.method === 'GET' && action === 'worktree-status') {
|
|
872
923
|
const task = store.getTask(id)
|
|
873
924
|
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
package/lib/launcher.js
CHANGED
|
@@ -296,6 +296,25 @@ export async function runTask({
|
|
|
296
296
|
agentPreset, permission, permissions, claimAs,
|
|
297
297
|
gitRunner, workspaceRegistry, resume = false,
|
|
298
298
|
}) {
|
|
299
|
+
// Проверка лимита параллелизма (#214)
|
|
300
|
+
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
301
|
+
if (maxConcurrent > 0) {
|
|
302
|
+
const activeCount = countActiveSessions({ store, agents })
|
|
303
|
+
// Если задача уже в очереди или это новый запуск, и слоты исчерпаны
|
|
304
|
+
const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
|
|
305
|
+
if (!isAlreadyRunning && activeCount >= maxConcurrent) {
|
|
306
|
+
const now = Date.now()
|
|
307
|
+
store.updateTask(task.id, { queuedAt: task.queuedAt > 0 ? task.queuedAt : now })
|
|
308
|
+
store.addTransition({
|
|
309
|
+
taskId: task.id,
|
|
310
|
+
fromCol: task.column,
|
|
311
|
+
toCol: task.column,
|
|
312
|
+
source: 'session',
|
|
313
|
+
detail: `превышен лимит параллельных сессий (${activeCount}/${maxConcurrent}), задача поставлена в очередь`,
|
|
314
|
+
})
|
|
315
|
+
return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
299
318
|
if (config?.permissionGateEnabled) {
|
|
300
319
|
const perm = permission || task.permission || 'read-only'
|
|
301
320
|
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
@@ -314,6 +333,10 @@ export async function runTask({
|
|
|
314
333
|
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
315
334
|
})
|
|
316
335
|
|
|
336
|
+
if (task.queuedAt > 0) {
|
|
337
|
+
store.updateTask(task.id, { queuedAt: 0 })
|
|
338
|
+
}
|
|
339
|
+
|
|
317
340
|
// Фиксируем запуск в истории задачи
|
|
318
341
|
if (typeof store.recordTaskRun === 'function') {
|
|
319
342
|
store.recordTaskRun(task.id, {
|
|
@@ -601,6 +624,11 @@ export function unqueueTask({ store, task }) {
|
|
|
601
624
|
* Сессия без живого агента в список не попадает — предлагать её значило бы
|
|
602
625
|
* предлагать очередь, из которой никто ничего не возьмёт.
|
|
603
626
|
*/
|
|
627
|
+
export function countActiveSessions({ store, agents }) {
|
|
628
|
+
const sessions = liveSessions({ store, agents })
|
|
629
|
+
return sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
|
|
630
|
+
}
|
|
631
|
+
|
|
604
632
|
export function liveSessions({ store, agents }) {
|
|
605
633
|
const byId = new Map()
|
|
606
634
|
const all = [...store.listTasks({ board: 'main' }), ...store.listTasks({ board: 'simple' })]
|
|
@@ -622,3 +650,45 @@ export function liveSessions({ store, agents }) {
|
|
|
622
650
|
}
|
|
623
651
|
return [...byId.values()]
|
|
624
652
|
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Автоматический запуск следующей задачи из очереди при освобождении слота (#214).
|
|
656
|
+
*/
|
|
657
|
+
export async function dispatchNextQueuedTask({
|
|
658
|
+
agents, store, config, mintSessionId, createMessage, cwdOf, logger,
|
|
659
|
+
agentPreset, permissions, workspaceRegistry, claimAs, gitRunner,
|
|
660
|
+
}) {
|
|
661
|
+
if (!store || !agents) return null
|
|
662
|
+
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
663
|
+
if (maxConcurrent > 0) {
|
|
664
|
+
const active = countActiveSessions({ store, agents })
|
|
665
|
+
if (active >= maxConcurrent) return null
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const queued = typeof store.listQueuedTasks === 'function' ? store.listQueuedTasks() : []
|
|
669
|
+
if (queued.length === 0) return null
|
|
670
|
+
|
|
671
|
+
const nextTask = queued[0]
|
|
672
|
+
try {
|
|
673
|
+
return await runTask({
|
|
674
|
+
agents,
|
|
675
|
+
store,
|
|
676
|
+
task: nextTask,
|
|
677
|
+
config,
|
|
678
|
+
provider: nextTask.provider || undefined,
|
|
679
|
+
model: nextTask.model || undefined,
|
|
680
|
+
mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
|
|
681
|
+
createMessage: createMessage || ((msg) => msg),
|
|
682
|
+
cwdOf,
|
|
683
|
+
logger,
|
|
684
|
+
agentPreset,
|
|
685
|
+
permissions,
|
|
686
|
+
workspaceRegistry,
|
|
687
|
+
claimAs: claimAs || 'queue-dispatcher',
|
|
688
|
+
gitRunner,
|
|
689
|
+
})
|
|
690
|
+
} catch (err) {
|
|
691
|
+
logger?.warn?.(`dsh-kanban: не удалось запустить задачу ${nextTask.id} из очереди: ${err?.message}`)
|
|
692
|
+
return null
|
|
693
|
+
}
|
|
694
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -382,6 +382,12 @@ export function exportBoard({ store }) {
|
|
|
382
382
|
* @returns {{imported: number, skipped: number, error?: string}}
|
|
383
383
|
*/
|
|
384
384
|
export function importBoard({ store, input }) {
|
|
385
|
+
let backupMeta = undefined
|
|
386
|
+
if (typeof store.backupDatabase === 'function') {
|
|
387
|
+
try {
|
|
388
|
+
backupMeta = store.backupDatabase()
|
|
389
|
+
} catch {}
|
|
390
|
+
}
|
|
385
391
|
if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
|
|
386
392
|
if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
|
|
387
393
|
if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
|
|
@@ -419,5 +425,5 @@ export function importBoard({ store, input }) {
|
|
|
419
425
|
imported += 1
|
|
420
426
|
}
|
|
421
427
|
|
|
422
|
-
return { imported, skipped }
|
|
428
|
+
return { imported, skipped, backup: backupMeta?.backup }
|
|
423
429
|
}
|
package/lib/store.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite'
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
|
-
import { mkdirSync } from 'node:fs'
|
|
16
|
+
import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { keyBetween } from './order.js'
|
|
19
19
|
import { DEFAULT_BOARDS, normalizeKind } from './config.js'
|
|
@@ -257,6 +257,37 @@ export function openStore({ dir }) {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
return {
|
|
260
|
+
backupDatabase() {
|
|
261
|
+
const backupsDir = join(dir, 'backups')
|
|
262
|
+
mkdirSync(backupsDir, { recursive: true })
|
|
263
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
264
|
+
const backupName = `kanban-backup-${stamp}.db`
|
|
265
|
+
const dest = join(backupsDir, backupName)
|
|
266
|
+
try {
|
|
267
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
|
268
|
+
} catch { /* wal checkpoint may not be strictly required */ }
|
|
269
|
+
copyFileSync(join(dir, 'kanban.db'), dest)
|
|
270
|
+
|
|
271
|
+
// Ротация: сохраняем только последние 10 бэкапов
|
|
272
|
+
try {
|
|
273
|
+
const files = readdirSync(backupsDir)
|
|
274
|
+
.filter((f) => f.startsWith('kanban-backup-') && f.endsWith('.db'))
|
|
275
|
+
.map((f) => ({ name: f, path: join(backupsDir, f), time: statSync(join(backupsDir, f)).mtimeMs }))
|
|
276
|
+
.sort((a, b) => b.time - a.time)
|
|
277
|
+
if (files.length > 10) {
|
|
278
|
+
for (const old of files.slice(10)) {
|
|
279
|
+
try { unlinkSync(old.path) } catch {}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch {}
|
|
283
|
+
return { backup: backupName, path: dest }
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
listQueuedTasks() {
|
|
287
|
+
const rows = db.prepare('SELECT * FROM tasks WHERE queuedAt > 0 AND archivedAt = 0 ORDER BY queuedAt ASC').all()
|
|
288
|
+
return rows.map(rowToTask)
|
|
289
|
+
},
|
|
290
|
+
|
|
260
291
|
listBoards() {
|
|
261
292
|
return q.boards.all()
|
|
262
293
|
},
|
package/lib/worktree.js
CHANGED
|
@@ -117,7 +117,7 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
|
117
117
|
if (!repoDir) return false
|
|
118
118
|
try {
|
|
119
119
|
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
|
|
120
|
-
return res.stdout === 'true'
|
|
120
|
+
return res.stdout?.trim() === 'true'
|
|
121
121
|
} catch {
|
|
122
122
|
return false
|
|
123
123
|
}
|
|
@@ -368,3 +368,161 @@ export async function cleanupTaskWorktree({
|
|
|
368
368
|
|
|
369
369
|
return { success: true, removed: true, branchDeleted: result.branchDeleted }
|
|
370
370
|
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Получить unified diff изменений в воркдереве задачи (#211).
|
|
374
|
+
*
|
|
375
|
+
* @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
|
|
376
|
+
* @returns {Promise<{diff: string, truncated: boolean, filesChanged: number}>}
|
|
377
|
+
*/
|
|
378
|
+
export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGitRunner }) {
|
|
379
|
+
if (!worktreePath) return { diff: '', truncated: false, filesChanged: 0 }
|
|
380
|
+
const isRepo = await isGitRepo(worktreePath, gitRunner)
|
|
381
|
+
if (!isRepo) return { diff: '', truncated: false, filesChanged: 0 }
|
|
382
|
+
|
|
383
|
+
let base = baseRef
|
|
384
|
+
if (!base) {
|
|
385
|
+
base = await determineBaseRef(worktreePath, gitRunner)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Сначала пробуем git diff base...HEAD, если base доступен
|
|
389
|
+
let diffOut = ''
|
|
390
|
+
if (base) {
|
|
391
|
+
const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
|
|
392
|
+
if (res.exitCode === 0) {
|
|
393
|
+
diffOut = res.stdout
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Дополнительно смотрим незакоммиченные изменения (unstaged + staged)
|
|
398
|
+
const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
|
|
399
|
+
if (unstaged.exitCode === 0 && unstaged.stdout.trim() !== '') {
|
|
400
|
+
diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Если и base...HEAD, и HEAD ничего не дали, берём чистый git diff
|
|
404
|
+
if (!diffOut.trim()) {
|
|
405
|
+
const fallback = await gitRunner(['diff'], worktreePath)
|
|
406
|
+
if (fallback.exitCode === 0) diffOut = fallback.stdout
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Подсчитываем число затронутых файлов
|
|
410
|
+
const fileLines = diffOut.split('\n').filter((l) => l.startsWith('diff --git'))
|
|
411
|
+
const filesChanged = fileLines.length
|
|
412
|
+
|
|
413
|
+
// Защита от переполнения: обрезаем diff до 250 КБ
|
|
414
|
+
const MAX_DIFF_BYTES = 250 * 1024
|
|
415
|
+
let truncated = false
|
|
416
|
+
if (Buffer.byteLength(diffOut, 'utf8') > MAX_DIFF_BYTES) {
|
|
417
|
+
diffOut = diffOut.slice(0, MAX_DIFF_BYTES) + '\n\n... [Diff truncated: exceeded 250 KB limit]'
|
|
418
|
+
truncated = true
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { diff: diffOut, truncated, filesChanged }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Получить список коммитов ветки задачи относительно базовой ветки (#211).
|
|
426
|
+
*
|
|
427
|
+
* @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
|
|
428
|
+
* @returns {Promise<Array<{hash: string, subject: string, author: string, date: string}>>}
|
|
429
|
+
*/
|
|
430
|
+
export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaultGitRunner }) {
|
|
431
|
+
if (!worktreePath) return []
|
|
432
|
+
const isRepo = await isGitRepo(worktreePath, gitRunner)
|
|
433
|
+
if (!isRepo) return []
|
|
434
|
+
|
|
435
|
+
let base = baseRef
|
|
436
|
+
if (!base) {
|
|
437
|
+
base = await determineBaseRef(worktreePath, gitRunner)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const range = base ? `${base}..HEAD` : '-n 20'
|
|
441
|
+
const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
442
|
+
if (logRes.exitCode !== 0 || !logRes.stdout.trim()) {
|
|
443
|
+
// fallback на последние 10 коммитов
|
|
444
|
+
const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
445
|
+
if (fallback.exitCode !== 0 || !fallback.stdout.trim()) return []
|
|
446
|
+
return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return logRes.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseCommitLine(line) {
|
|
453
|
+
const parts = line.split('\t')
|
|
454
|
+
if (parts.length < 4) return null
|
|
455
|
+
return {
|
|
456
|
+
hash: parts[0],
|
|
457
|
+
author: parts[1],
|
|
458
|
+
date: parts[2],
|
|
459
|
+
subject: parts[3],
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Слияние ветки воркдерева в основную ветку репозитория (--no-ff) при приёмке (#212).
|
|
465
|
+
*
|
|
466
|
+
* @param {{task: object, targetBranch?: string, gitRunner?: Function, workspaceRegistry?: object}} options
|
|
467
|
+
* @returns {Promise<{success: boolean, merged: boolean, error?: string}>}
|
|
468
|
+
*/
|
|
469
|
+
export async function mergeTaskWorktree({
|
|
470
|
+
task, targetBranch, gitRunner = defaultGitRunner, workspaceRegistry,
|
|
471
|
+
}) {
|
|
472
|
+
if (!task.worktree || !task.branch) {
|
|
473
|
+
return { success: false, merged: false, error: 'no-worktree-or-branch' }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 1. Проверяем чистоту воркдерева
|
|
477
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
|
|
478
|
+
if (dirtyCheck.dirty) {
|
|
479
|
+
return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 2. Находим корень основного репозитория (из .git файла воркдерева или config)
|
|
483
|
+
let mainRepoDir = ''
|
|
484
|
+
const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
|
|
485
|
+
if (revParse.exitCode === 0 && revParse.stdout.trim()) {
|
|
486
|
+
const commonDir = revParse.stdout.trim()
|
|
487
|
+
mainRepoDir = commonDir.endsWith('/.git') ? commonDir.slice(0, -5) : commonDir
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!mainRepoDir) {
|
|
491
|
+
return { success: false, merged: false, error: 'main-repo-not-found' }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 3. Определяем целевую ветку для слияния
|
|
495
|
+
let target = targetBranch
|
|
496
|
+
if (!target) {
|
|
497
|
+
target = await determineBaseRef(mainRepoDir, gitRunner)
|
|
498
|
+
}
|
|
499
|
+
if (!target) target = 'main'
|
|
500
|
+
|
|
501
|
+
// 4. Переключаемся или проверяем, что в целевой ветке в основном репо
|
|
502
|
+
const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
|
|
503
|
+
if (currentBranch.stdout.trim() !== target) {
|
|
504
|
+
const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
|
|
505
|
+
if (checkoutRes.exitCode !== 0) {
|
|
506
|
+
return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 5. Выполняем git merge --no-ff
|
|
511
|
+
const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
|
|
512
|
+
const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
|
|
513
|
+
if (mergeRes.exitCode !== 0) {
|
|
514
|
+
// В случае конфликта откатываем мерж
|
|
515
|
+
await gitRunner(['merge', '--abort'], mainRepoDir)
|
|
516
|
+
return { success: false, merged: false, error: 'merge-conflict', detail: mergeRes.stderr || mergeRes.stdout }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 6. Очищаем воркдерево задачи
|
|
520
|
+
await cleanupTaskWorktree({
|
|
521
|
+
task,
|
|
522
|
+
workspaceRegistry,
|
|
523
|
+
gitRunner,
|
|
524
|
+
force: true,
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
return { success: true, merged: true, targetBranch: target }
|
|
528
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|