@goodandready/dsh-kanban 0.1.23 → 0.1.25
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 +15 -0
- package/docs/design/DESIGN.md +53 -0
- package/lib/client.js +39 -1
- package/lib/config.js +4 -0
- package/lib/index.js +34 -0
- package/lib/launcher.js +76 -8
- package/lib/store.js +6 -4
- package/lib/worktree.js +339 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -260,6 +260,21 @@ compares the requested names against the actual exports. It is deliberately not
|
|
|
260
260
|
part of `npm test`: the test suite runs without a core and without a profile.
|
|
261
261
|
Run it on the machine where the harness lives, before publishing.
|
|
262
262
|
|
|
263
|
+
## 💻 Cross-Platform Path Handling
|
|
264
|
+
|
|
265
|
+
Task working directories (`resolveCwd`) respect operating system path conventions.
|
|
266
|
+
On Windows, repository root fencing accounts for drive letters and backslash path
|
|
267
|
+
separators, ensuring full test and runtime compatibility across POSIX and Windows.
|
|
268
|
+
|
|
269
|
+
## 🌳 Git Worktree Isolation
|
|
270
|
+
|
|
271
|
+
When in-progress work begins on a repository task, the agent session is automatically
|
|
272
|
+
isolated into a dedicated git worktree (`$DSH_HOME/worktrees/<repo-key>/<task-id>/` on branch
|
|
273
|
+
`task/<id>-<slug>`). The worktree is registered in DSH's `workspaceRegistry` for full UI
|
|
274
|
+
visibility and multi-agent concurrency protection. During the `cleanup` phase, uncommitted files
|
|
275
|
+
are detected via `git status --porcelain` to prevent data loss, followed by clean worktree and workspace
|
|
276
|
+
deregistration.
|
|
277
|
+
|
|
263
278
|
## 📄 License
|
|
264
279
|
|
|
265
280
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# DESIGN.md — dsh-kanban
|
|
2
|
+
|
|
3
|
+
## Product / Purpose
|
|
4
|
+
- Назначение: Интегрированная доска задач для DeepSeek Harness с двусторонней сверкой с Gitea, автоматическим подхватом сессий агентов и управлением жизненным циклом.
|
|
5
|
+
- Аудитория: Разработчики и операторы DeepSeek Harness, ведущие проектные задачи и сессии агентов.
|
|
6
|
+
- Статус: Production, версия 0.1.23+.
|
|
7
|
+
|
|
8
|
+
## User Surfaces
|
|
9
|
+
- Web/UI: Экран доски в центральной колонке интерфейса DSH (mountBoard), выдвигающаяся панель задач, фильтры, группировки (по проектам, по исполнителю), метрики переходов.
|
|
10
|
+
- DSH UI / settings / slots:
|
|
11
|
+
- Карточка настроек в слоте settings.plugin.item (пространство dsh-kanban).
|
|
12
|
+
- Чип активной задачи в conversation.session.header.utilities.
|
|
13
|
+
- Пункт бокового меню для вызова экрана доски.
|
|
14
|
+
- API: HTTP эндпоинты /dsh-kanban/* (board, snapshot, task, sync, webhook, metrics, whoami).
|
|
15
|
+
- CLI: node tools/compat.mjs (проверка совместимости экспортов ядра).
|
|
16
|
+
- Документация: README.md, docs/README.ru.md, docs/README.zh.md, index.md.
|
|
17
|
+
|
|
18
|
+
## Visual Direction
|
|
19
|
+
- Атмосфера: Строгий утилитарный инструментальный интерфейс в едином стиле темы DeepSeek Harness.
|
|
20
|
+
- Утверждённые референсы и что из них берём: Системные карточки DSH (стили заголовков, отступы, шрифты, цветовые токены --dsw-alias-*).
|
|
21
|
+
- Не копировать: Внешние неинтегрированные темы, разнородные цвета рамок и теней.
|
|
22
|
+
|
|
23
|
+
## Foundations
|
|
24
|
+
- Цвета и роли: Использование CSS-переменных DSH темы (--dsw-alias-bg-layer-3, --dsw-alias-border-l2, --dsw-alias-label-primary, --dsw-alias-label-secondary).
|
|
25
|
+
- Типографика: Системный стек шрифтов DSH (inherit, 13-14px базовый размер, 11-12px для бейджей и служебных меток).
|
|
26
|
+
- Сетка, отступы, responsive: 5 стандартных колонок (backlog, ready, progress, review, done) с горизонтальным скроллом и адаптивными карточками.
|
|
27
|
+
- Accessibility: Нативные кнопки button, role="status" для сообщений, семантичные поля ввода с aria-label/label.
|
|
28
|
+
|
|
29
|
+
## Components And States
|
|
30
|
+
- Компоненты: BoardScreen, Column, Card, TaskDrawer, KanbanSettingsCard, TaskChip, MetricsModal.
|
|
31
|
+
- Loading / empty / error / success:
|
|
32
|
+
- Загрузка: состояние скелетона/лоадера без блокировки скролла.
|
|
33
|
+
- Пустые колонки: пунктирная область перетаскивания.
|
|
34
|
+
- Ошибки: всплывающий баннер с ключом локализации t('error.' + err).
|
|
35
|
+
- Формы, валидация и действия: Валидация обязательных полей при создании задачи, блокировка повторных кликов при сохранении.
|
|
36
|
+
|
|
37
|
+
## User Flows
|
|
38
|
+
- Критические сценарии:
|
|
39
|
+
1. Создание задачи / подхват из Gitea Issue.
|
|
40
|
+
2. Перемещение карточки между колонками человеком или через инструмент агента board_move.
|
|
41
|
+
3. Запуск сессии агента по карточке с автоматическим назначением ответственного.
|
|
42
|
+
4. Автоматическое обновление статуса (waiting) по событиям ядра (session/event).
|
|
43
|
+
5. Экспорт и импорт снимка доски в JSON.
|
|
44
|
+
|
|
45
|
+
## Do / Don't
|
|
46
|
+
- Do: Использовать только токены темы --dsw-alias-*, очищать все слушатели событий при размонтировании, соблюдать порядок хуков React.
|
|
47
|
+
- Don't: Не переводить карточку в done агентом (колонка done фиксируется только по факту закрытия issue и ветки).
|
|
48
|
+
|
|
49
|
+
## Locked Design Decisions
|
|
50
|
+
- 2026-08-20 — Карточка настроек монтируется в settings.plugin.item с резервом в settings.section.
|
|
51
|
+
- 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
|
|
52
|
+
- 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
|
|
53
|
+
- 2026-09-03 — Изоляция сессий задач в git worktree ($DSH_HOME/worktrees/<repo-key>/<task-id>/ на ветке task/<id>-<slug>), регистрация воркдерева в workspaceRegistry, проверка dirtyFiles и безопасная очистка в фазе cleanup (#193).
|
package/lib/client.js
CHANGED
|
@@ -858,6 +858,10 @@ window.__ModuleLoader__.load({
|
|
|
858
858
|
'field.boardToolEnabled': 'Let the agent move cards',
|
|
859
859
|
'field.pushToGitea': 'Close the issue in Gitea',
|
|
860
860
|
'field.webhookSecretRef': 'Webhook credential name',
|
|
861
|
+
'field.worktreeIsolation': 'Isolate tasks in git worktrees',
|
|
862
|
+
'hint.worktreeIsolation': 'Creates a separate git worktree for each task session to prevent file collisions.',
|
|
863
|
+
'field.worktreeRoot': 'Worktree root directory',
|
|
864
|
+
'hint.worktreeRoot': 'Custom folder for task worktrees. Leave empty to use $DSH_HOME/worktrees.',
|
|
861
865
|
'hint.pushToGitea': 'A card reaching Done closes its issue. The board sets no labels: the stage is already visible from the branch and the pull request.',
|
|
862
866
|
'hint.webhookSecretRef': 'NAME of a DSH credential holding the Gitea webhook secret. Empty leaves only polling.',
|
|
863
867
|
'hint.syncIntervalSec': 'Zero turns the check off. Below fifteen seconds is raised to fifteen.',
|
|
@@ -1069,6 +1073,9 @@ window.__ModuleLoader__.load({
|
|
|
1069
1073
|
'panel.startHint': 'A dedicated agent session opens for this task. Branch and worktree are created by the agent after its preflight, not here.',
|
|
1070
1074
|
'panel.workDir': 'Working folder: {path}',
|
|
1071
1075
|
'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
|
|
1076
|
+
'panel.worktree': 'Worktree: {path}',
|
|
1077
|
+
'panel.cleanupWorktree': 'Clean Worktree',
|
|
1078
|
+
'panel.worktreeDirty': 'Uncommitted changes in worktree: {files}',
|
|
1072
1079
|
'panel.priority': 'Priority',
|
|
1073
1080
|
'panel.priorityNone': 'No priority',
|
|
1074
1081
|
'priority.high': 'High',
|
|
@@ -1125,6 +1132,10 @@ window.__ModuleLoader__.load({
|
|
|
1125
1132
|
'field.boardToolEnabled': '允许 Agent 移动卡片',
|
|
1126
1133
|
'field.pushToGitea': '在 Gitea 中关闭 issue',
|
|
1127
1134
|
'field.webhookSecretRef': 'Webhook 凭据名称',
|
|
1135
|
+
'field.worktreeIsolation': '在 git worktree 中隔离任务',
|
|
1136
|
+
'hint.worktreeIsolation': '为每个任务会话创建独立的 git worktree 以防止文件冲突。',
|
|
1137
|
+
'field.worktreeRoot': 'Worktree 根目录',
|
|
1138
|
+
'hint.worktreeRoot': '任务 worktree 的自定义目录。留空则使用 $DSH_HOME/worktrees。',
|
|
1128
1139
|
'hint.pushToGitea': '卡片进入「已完成」即关闭其 issue。看板不设置任何标签:阶段从分支和 pull request 上已经看得出来。',
|
|
1129
1140
|
'hint.webhookSecretRef': '保存 Gitea webhook 密钥的 DSH 凭据的名称。留空则只剩轮询。',
|
|
1130
1141
|
'hint.syncIntervalSec': '填 0 关闭核对。小于十五秒会被提到十五秒。',
|
|
@@ -1336,6 +1347,9 @@ window.__ModuleLoader__.load({
|
|
|
1336
1347
|
'panel.startHint': '会为该任务打开一个专属的 Agent 会话。分支和 worktree 由 Agent 在自检之后创建,不在这里。',
|
|
1337
1348
|
'panel.workDir': '工作目录:{path}',
|
|
1338
1349
|
'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
|
|
1350
|
+
'panel.worktree': '工作区:{path}',
|
|
1351
|
+
'panel.cleanupWorktree': '清理工作区',
|
|
1352
|
+
'panel.worktreeDirty': '工作区中存在未提交的更改:{files}',
|
|
1339
1353
|
'panel.priority': '优先级',
|
|
1340
1354
|
'panel.priorityNone': '无优先级',
|
|
1341
1355
|
'priority.high': '高',
|
|
@@ -1395,6 +1409,8 @@ window.__ModuleLoader__.load({
|
|
|
1395
1409
|
{ field: 'boardToolEnabled', kind: 'bool', group: 'sync' },
|
|
1396
1410
|
{ field: 'pushToGitea', kind: 'bool', group: 'sync' },
|
|
1397
1411
|
{ field: 'webhookSecretRef', kind: 'text', group: 'sync' },
|
|
1412
|
+
{ field: 'worktreeIsolation', kind: 'bool', group: 'general' },
|
|
1413
|
+
{ field: 'worktreeRoot', kind: 'text', group: 'general' },
|
|
1398
1414
|
]
|
|
1399
1415
|
|
|
1400
1416
|
function hintKey(spec) {
|
|
@@ -2507,6 +2523,11 @@ window.__ModuleLoader__.load({
|
|
|
2507
2523
|
target: '_blank', rel: 'noreferrer noopener',
|
|
2508
2524
|
}, ' · ' + t('panel.issue') + ' ↗')
|
|
2509
2525
|
: null),
|
|
2526
|
+
openTask.worktree
|
|
2527
|
+
? React.createElement('div', { className: 'dkb-note', style: { wordBreak: 'break-all' } },
|
|
2528
|
+
t('panel.worktree', { path: openTask.worktree })
|
|
2529
|
+
+ (openTask.branch ? ' (' + openTask.branch + ')' : ''))
|
|
2530
|
+
: null,
|
|
2510
2531
|
// Пустое тело — отсутствие раздела, а не пустой раздел с заголовком.
|
|
2511
2532
|
body === null
|
|
2512
2533
|
? React.createElement('p', { className: 'dkb-note' }, t('panel.bodyLoading'))
|
|
@@ -2717,7 +2738,24 @@ window.__ModuleLoader__.load({
|
|
|
2717
2738
|
},
|
|
2718
2739
|
}, t('panel.archive'))
|
|
2719
2740
|
: null,
|
|
2720
|
-
|
|
2741
|
+
openTask.column === 'cleanup' && openTask.worktree
|
|
2742
|
+
? React.createElement('button', {
|
|
2743
|
+
type: 'button', className: 'dkb-discard',
|
|
2744
|
+
onClick: async () => {
|
|
2745
|
+
try {
|
|
2746
|
+
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/cleanup-worktree', {
|
|
2747
|
+
method: 'POST', body: '{}',
|
|
2748
|
+
})
|
|
2749
|
+
if (out && out.error === 'worktree-dirty') {
|
|
2750
|
+
alert(t('panel.worktreeDirty', { files: (out.files || []).join(', ') }))
|
|
2751
|
+
} else {
|
|
2752
|
+
setOpenTask(null)
|
|
2753
|
+
await load(board)
|
|
2754
|
+
}
|
|
2755
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2756
|
+
},
|
|
2757
|
+
}, t('panel.cleanupWorktree'))
|
|
2758
|
+
: null,
|
|
2721
2759
|
),
|
|
2722
2760
|
React.createElement('button', {
|
|
2723
2761
|
type: 'button', className: 'dkb-save', disabled: starting || model === '',
|
package/lib/config.js
CHANGED
|
@@ -71,6 +71,8 @@ export const CONFIG_DEFAULTS = {
|
|
|
71
71
|
boardToolEnabled: false,
|
|
72
72
|
webhookSecretRef: '',
|
|
73
73
|
pushToGitea: true,
|
|
74
|
+
worktreeIsolation: true,
|
|
75
|
+
worktreeRoot: '',
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
/** Пояснения к полям; повторно используются схемой и карточкой настроек. */
|
|
@@ -91,6 +93,8 @@ export const CONFIG_HINTS = {
|
|
|
91
93
|
boardToolEnabled: 'Разрешить агенту двигать карточки инструментом board_move. Выключено, пока скилл воркфлоу запрещает CLI трогать канбан.',
|
|
92
94
|
webhookSecretRef: 'ИМЯ учётной записи DSH с секретом вебхука Gitea. Пусто — вебхук не принимается, остаётся только опрос.',
|
|
93
95
|
pushToGitea: 'Закрывать issue в Gitea, когда карточка уходит в колонку «Выполнено».',
|
|
96
|
+
worktreeIsolation: 'Изолировать сессии задач в отдельные git worktrees для предотвращения коллизий.',
|
|
97
|
+
worktreeRoot: 'Каталог для воркдеревьев задач. Пусто — $DSH_HOME/worktrees или .worktrees проекта.',
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
/** Поле настроек с пределом числа карточек; задан не у всех колонок. */
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
runTask, resolveModel, buildStartMessage,
|
|
30
30
|
runBatch, queueTask, unqueueTask, liveSessions,
|
|
31
31
|
} from './launcher.js'
|
|
32
|
+
import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
|
|
32
33
|
import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
|
|
33
34
|
import { createGiteaClient } from './gitea.js'
|
|
34
35
|
import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
|
|
@@ -661,6 +662,7 @@ export function apply(ctx, config) {
|
|
|
661
662
|
agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
|
|
662
663
|
permission: typeof input.permission === 'string' ? input.permission : undefined,
|
|
663
664
|
permissions: ctx.get('permissionPresets'),
|
|
665
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
664
666
|
claimAs: await whoAmI(),
|
|
665
667
|
}))
|
|
666
668
|
} catch (error) {
|
|
@@ -698,10 +700,42 @@ export function apply(ctx, config) {
|
|
|
698
700
|
if (out?.task !== undefined) {
|
|
699
701
|
queueOutbound(id, out.task.column)
|
|
700
702
|
if (acted !== undefined) out.command = acted
|
|
703
|
+
if (input.column === 'cleanup' && was?.worktree) {
|
|
704
|
+
const dirty = await checkWorktreeDirty({ worktreePath: was.worktree })
|
|
705
|
+
if (dirty.dirty) {
|
|
706
|
+
out.worktreeWarning = 'worktree-dirty'
|
|
707
|
+
out.dirtyFiles = dirty.files
|
|
708
|
+
}
|
|
709
|
+
}
|
|
701
710
|
}
|
|
702
711
|
reply(res, out)
|
|
703
712
|
return
|
|
704
713
|
}
|
|
714
|
+
if (req.method === 'POST' && action === 'cleanup-worktree') {
|
|
715
|
+
const task = store.getTask(id)
|
|
716
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
717
|
+
const out = await cleanupTaskWorktree({
|
|
718
|
+
store,
|
|
719
|
+
task,
|
|
720
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
721
|
+
force: input?.force === true,
|
|
722
|
+
})
|
|
723
|
+
reply(res, out)
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
if (req.method === 'GET' && action === 'worktree-status') {
|
|
727
|
+
const task = store.getTask(id)
|
|
728
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
729
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
730
|
+
reply(res, {
|
|
731
|
+
worktree: task.worktree || '',
|
|
732
|
+
branch: task.branch || '',
|
|
733
|
+
workspaceId: task.workspaceId || '',
|
|
734
|
+
dirty: dirtyCheck.dirty,
|
|
735
|
+
files: dirtyCheck.files,
|
|
736
|
+
})
|
|
737
|
+
return
|
|
738
|
+
}
|
|
705
739
|
if (req.method === 'POST' && action === 'stop') {
|
|
706
740
|
const task = store.getTask(id)
|
|
707
741
|
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
package/lib/launcher.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// Пакеты ядра здесь не импортируются: идентификатор сессии и сборка сообщения
|
|
13
13
|
// приходят параметрами. Так модуль проверяется без харнесса и без сети.
|
|
14
14
|
|
|
15
|
-
import { isAbsolute, join, resolve } from 'node:path'
|
|
15
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
16
|
+
import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Хвост своей задачи — УСЛОВИЕ, а не приказ.
|
|
@@ -110,7 +111,8 @@ export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
|
|
|
110
111
|
|
|
111
112
|
const target = resolve(join(root, repo))
|
|
112
113
|
const fence = resolve(root)
|
|
113
|
-
|
|
114
|
+
const fenceSep = fence.endsWith(sep) || fence.endsWith('/') ? fence : fence + sep
|
|
115
|
+
if (target !== fence && !target.startsWith(fenceSep) && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
|
|
114
116
|
throw new Error('имя репозитория выводит за пределы корня проектов')
|
|
115
117
|
}
|
|
116
118
|
return target
|
|
@@ -145,7 +147,7 @@ export function applyPermission({ permissions, agent, name }) {
|
|
|
145
147
|
|
|
146
148
|
export async function obtainAgent({
|
|
147
149
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
148
|
-
agentPreset, permission, permissions,
|
|
150
|
+
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
149
151
|
}) {
|
|
150
152
|
const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
|
|
151
153
|
|
|
@@ -164,12 +166,51 @@ export async function obtainAgent({
|
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
|
|
169
|
+
let cwd = resolveCwd(task, config, cwdOf)
|
|
170
|
+
let worktreePath = task?.worktree || undefined
|
|
171
|
+
let branchName = task?.branch || undefined
|
|
172
|
+
let workspaceId = task?.workspaceId || undefined
|
|
173
|
+
let worktreeCreated = false
|
|
174
|
+
|
|
175
|
+
if (config?.worktreeIsolation !== false && task?.repo) {
|
|
176
|
+
try {
|
|
177
|
+
const created = await createTaskWorktree({
|
|
178
|
+
task,
|
|
179
|
+
repoDir: cwd,
|
|
180
|
+
config,
|
|
181
|
+
gitRunner,
|
|
182
|
+
cwdOf,
|
|
183
|
+
})
|
|
184
|
+
if (created) {
|
|
185
|
+
worktreePath = created.worktreePath
|
|
186
|
+
branchName = created.branchName
|
|
187
|
+
worktreeCreated = created.created === true
|
|
188
|
+
cwd = created.worktreePath
|
|
189
|
+
|
|
190
|
+
if (workspaceRegistry) {
|
|
191
|
+
workspaceId = await registerDshWorkspace({
|
|
192
|
+
workspaceRegistry,
|
|
193
|
+
worktreePath,
|
|
194
|
+
title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
|
|
200
|
+
}
|
|
201
|
+
} else if (worktreePath) {
|
|
202
|
+
cwd = worktreePath
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const meta = { cwd }
|
|
206
|
+
if (typeof agentPreset === 'string' && agentPreset !== '') meta.agentPreset = agentPreset
|
|
207
|
+
if (workspaceId) meta.workspaceId = workspaceId
|
|
208
|
+
|
|
168
209
|
const handle = await agents.create({
|
|
169
210
|
sessionId: mintSessionId(),
|
|
170
211
|
// Профиль — факт создания сессии: у идущей его не сменить, поэтому он
|
|
171
212
|
// едет здесь, а не отдельным вызовом после.
|
|
172
|
-
meta
|
|
213
|
+
meta,
|
|
173
214
|
agentOptions: { provider, model },
|
|
174
215
|
})
|
|
175
216
|
const access = applyPermission({ permissions, agent: handle.agent, name: permission })
|
|
@@ -182,7 +223,15 @@ export async function obtainAgent({
|
|
|
182
223
|
error.status = 409
|
|
183
224
|
throw error
|
|
184
225
|
}
|
|
185
|
-
return {
|
|
226
|
+
return {
|
|
227
|
+
agent: handle.agent,
|
|
228
|
+
sessionId: handle.agent.session.id,
|
|
229
|
+
mode: 'created',
|
|
230
|
+
worktree: worktreePath,
|
|
231
|
+
branch: branchName,
|
|
232
|
+
workspaceId,
|
|
233
|
+
worktreeCreated,
|
|
234
|
+
}
|
|
186
235
|
}
|
|
187
236
|
|
|
188
237
|
/**
|
|
@@ -240,10 +289,11 @@ export async function runTask({
|
|
|
240
289
|
agents, store, task, config, provider, model,
|
|
241
290
|
mintSessionId, createMessage, cwdOf, logger, text,
|
|
242
291
|
agentPreset, permission, permissions, claimAs,
|
|
292
|
+
gitRunner, workspaceRegistry,
|
|
243
293
|
}) {
|
|
244
294
|
const taken = await obtainAgent({
|
|
245
295
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
246
|
-
agentPreset, permission, permissions,
|
|
296
|
+
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
247
297
|
})
|
|
248
298
|
|
|
249
299
|
await taken.agent.whenIdle()
|
|
@@ -255,12 +305,30 @@ export async function runTask({
|
|
|
255
305
|
}))
|
|
256
306
|
|
|
257
307
|
const before = store.getTask(task.id)
|
|
258
|
-
const patch = {
|
|
308
|
+
const patch = {
|
|
309
|
+
sessionId: taken.sessionId,
|
|
310
|
+
provider,
|
|
311
|
+
model,
|
|
312
|
+
...(taken.worktree ? { worktree: taken.worktree } : {}),
|
|
313
|
+
...(taken.branch ? { branch: taken.branch } : {}),
|
|
314
|
+
...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
|
|
315
|
+
}
|
|
259
316
|
store.updateTask(task.id, patch)
|
|
260
317
|
// Запустил — значит отвечаешь: свободная задача становится твоей, чужая
|
|
261
318
|
// остаётся чужой.
|
|
262
319
|
claimOnStart({ store, task: before, login: claimAs })
|
|
263
320
|
|
|
321
|
+
// Изоляция сессии в отдельном воркдереве фиксируется в журнале.
|
|
322
|
+
if (taken.worktreeCreated) {
|
|
323
|
+
store.addTransition({
|
|
324
|
+
taskId: task.id,
|
|
325
|
+
fromCol: before.column,
|
|
326
|
+
toCol: before.column,
|
|
327
|
+
source: 'session',
|
|
328
|
+
detail: `изоляция: создано воркдерево ${taken.worktree} (${taken.branch})`,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
264
332
|
// Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
|
|
265
333
|
// поймёт, куда делась переписка.
|
|
266
334
|
if (taken.mode === 'created' && before.sessionId) {
|
package/lib/store.js
CHANGED
|
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
40
40
|
labels TEXT NOT NULL DEFAULT '[]',
|
|
41
41
|
branch TEXT,
|
|
42
42
|
worktree TEXT,
|
|
43
|
+
workspaceId TEXT NOT NULL DEFAULT '',
|
|
43
44
|
sessionId TEXT,
|
|
44
45
|
model TEXT,
|
|
45
46
|
provider TEXT,
|
|
@@ -82,7 +83,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
82
83
|
/** Поля задачи, которые разрешено править через updateTask. */
|
|
83
84
|
const WRITABLE = [
|
|
84
85
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
85
|
-
'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
86
|
+
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
86
87
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
87
88
|
]
|
|
88
89
|
|
|
@@ -132,6 +133,7 @@ export function openStore({ dir }) {
|
|
|
132
133
|
['milestone', "TEXT NOT NULL DEFAULT ''"],
|
|
133
134
|
['priority', "TEXT NOT NULL DEFAULT ''"],
|
|
134
135
|
['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
136
|
+
['workspaceId', "TEXT NOT NULL DEFAULT ''"],
|
|
135
137
|
]) {
|
|
136
138
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
137
139
|
}
|
|
@@ -260,15 +262,15 @@ export function openStore({ dir }) {
|
|
|
260
262
|
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
261
263
|
db.prepare(`INSERT INTO tasks
|
|
262
264
|
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
263
|
-
labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
|
|
265
|
+
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
264
266
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
|
|
265
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
267
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
266
268
|
id, board, column, position,
|
|
267
269
|
input.title ?? '', input.body ?? '',
|
|
268
270
|
input.owner ?? null, input.repo ?? null,
|
|
269
271
|
input.issueNumber ?? null, input.issueUrl ?? null,
|
|
270
272
|
JSON.stringify(input.labels ?? []),
|
|
271
|
-
input.branch ?? null, input.worktree ?? null,
|
|
273
|
+
input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
|
|
272
274
|
input.sessionId ?? null, input.model ?? null, input.provider ?? null,
|
|
273
275
|
input.waiting ? 1 : 0,
|
|
274
276
|
Number(input.syncedAt) || 0,
|
package/lib/worktree.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Управляемые воркдеревья для сессий агентов под задачи (Issue #193).
|
|
2
|
+
//
|
|
3
|
+
// Изоляция сессии агента в отдельном git worktree защищает основной
|
|
4
|
+
// репозиторий от параллельных правок нескольких задач, предотвращает
|
|
5
|
+
// перезапись веток и фиксирует рабочее окружение карточки.
|
|
6
|
+
//
|
|
7
|
+
// Жизненный цикл:
|
|
8
|
+
// 1. При переходе в In Progress создаётся воркдерево task/<id>-<slug>
|
|
9
|
+
// 2. Воркдерево регистрируется в DSH workspaceRegistry как рабочий каталог
|
|
10
|
+
// 3. Сессия агента запускается с cwd = worktreePath
|
|
11
|
+
// 4. В колонке Cleanup проверяются незакоммиченные файлы (dirtyFiles)
|
|
12
|
+
// 5. После подтверждения воркдерево удаляется, воркспейс дерегистрируется.
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process'
|
|
15
|
+
import { promisify } from 'node:util'
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Стандартный исполнитель git-команд.
|
|
23
|
+
* Принимает аргументы массивом, исключая шелл-инъекции.
|
|
24
|
+
*/
|
|
25
|
+
export async function defaultGitRunner(args, cwd) {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
28
|
+
cwd,
|
|
29
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
30
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
31
|
+
})
|
|
32
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 }
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const error = new Error(`git ${args.join(' ')} failed: ${err.stderr || err.message}`)
|
|
35
|
+
error.code = err.code || 1
|
|
36
|
+
error.stdout = err.stdout ? String(err.stdout).trim() : ''
|
|
37
|
+
error.stderr = err.stderr ? String(err.stderr).trim() : ''
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Преобразовать строку в безопасный фрагмент имени ветки или каталога.
|
|
44
|
+
*/
|
|
45
|
+
export function slugify(text) {
|
|
46
|
+
if (typeof text !== 'string') return 'task'
|
|
47
|
+
const slug = text
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.normalize('NFD')
|
|
50
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
51
|
+
.replace(/[^a-z0-9а-яё_-]+/gi, '-')
|
|
52
|
+
.replace(/-+/g, '-')
|
|
53
|
+
.replace(/^-|-$/g, '')
|
|
54
|
+
.slice(0, 40)
|
|
55
|
+
return slug || 'task'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Безопасный ключ репозитория для каталога воркдеревьев.
|
|
60
|
+
*/
|
|
61
|
+
export function safeRepoKey(task) {
|
|
62
|
+
const owner = typeof task?.owner === 'string' ? task.owner.trim() : ''
|
|
63
|
+
const repo = typeof task?.repo === 'string' ? task.repo.trim() : ''
|
|
64
|
+
if (owner && repo) return `${owner}_${repo}`
|
|
65
|
+
if (repo) return repo
|
|
66
|
+
return 'local'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Определить корневой каталог для всех управляемых воркдеревьев.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
|
|
73
|
+
const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
|
|
74
|
+
if (custom !== '') {
|
|
75
|
+
return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
|
|
76
|
+
}
|
|
77
|
+
if (typeof env?.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') {
|
|
78
|
+
return resolve(join(env.DSH_HOME.trim(), 'worktrees'))
|
|
79
|
+
}
|
|
80
|
+
const projectRoot = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
|
|
81
|
+
? config.defaultProjectRoot.trim()
|
|
82
|
+
: cwdOf()
|
|
83
|
+
return resolve(join(projectRoot, '.worktrees'))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Вычислить канонический путь к воркдереву задачи с защитой от path traversal.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
90
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
91
|
+
const repoKey = safeRepoKey(task)
|
|
92
|
+
const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
|
|
93
|
+
const target = resolve(join(root, repoKey, taskId))
|
|
94
|
+
|
|
95
|
+
const fenceSep = root.endsWith(sep) || root.endsWith('/') ? root : root + sep
|
|
96
|
+
if (target !== root && !target.startsWith(fenceSep) && !target.startsWith(root + '/')) {
|
|
97
|
+
throw new Error('путь воркдерева выходит за пределы корня воркдеревьев')
|
|
98
|
+
}
|
|
99
|
+
return target
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Сформировать имя ветки задачи.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveTaskBranch(task) {
|
|
106
|
+
const prefix = typeof task?.issueNumber === 'number'
|
|
107
|
+
? String(task.issueNumber)
|
|
108
|
+
: (typeof task?.id === 'string' ? task.id.slice(0, 8) : 'task')
|
|
109
|
+
const slug = slugify(task?.title || '')
|
|
110
|
+
return `task/${prefix}-${slug}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Проверить, является ли каталог git-репозиторием.
|
|
115
|
+
*/
|
|
116
|
+
export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
117
|
+
if (!repoDir) return false
|
|
118
|
+
try {
|
|
119
|
+
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
|
|
120
|
+
return res.stdout === 'true'
|
|
121
|
+
} catch {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Получить список существующих путей воркдеревьев для репозитория.
|
|
128
|
+
*/
|
|
129
|
+
export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await gitRunner(['worktree', 'list', '--porcelain'], repoDir)
|
|
132
|
+
const paths = []
|
|
133
|
+
for (const line of res.stdout.split('\n')) {
|
|
134
|
+
if (line.startsWith('worktree ')) {
|
|
135
|
+
paths.push(resolve(line.slice(9).trim()))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return paths
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Определить базовую ветку для создания воркдерева.
|
|
146
|
+
*/
|
|
147
|
+
export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
|
|
148
|
+
for (const candidate of ['origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
|
|
149
|
+
try {
|
|
150
|
+
await gitRunner(['rev-parse', '--verify', '--quiet', candidate], repoDir)
|
|
151
|
+
return candidate
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return 'HEAD'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Создать или переиспользовать воркдерево под задачу.
|
|
161
|
+
*/
|
|
162
|
+
export async function createTaskWorktree({
|
|
163
|
+
task,
|
|
164
|
+
repoDir,
|
|
165
|
+
config,
|
|
166
|
+
gitRunner = defaultGitRunner,
|
|
167
|
+
env,
|
|
168
|
+
cwdOf,
|
|
169
|
+
}) {
|
|
170
|
+
if (!repoDir) return undefined
|
|
171
|
+
const isGit = await isGitRepo(repoDir, gitRunner)
|
|
172
|
+
if (!isGit) return undefined
|
|
173
|
+
|
|
174
|
+
const worktreePath = resolveTaskWorktreePath({ task, config, env, cwdOf })
|
|
175
|
+
const branchName = resolveTaskBranch(task)
|
|
176
|
+
|
|
177
|
+
// Проверяем, не зарегистрировано ли уже это воркдерево
|
|
178
|
+
const existing = await listWorktrees(repoDir, gitRunner)
|
|
179
|
+
if (existing.includes(resolve(worktreePath))) {
|
|
180
|
+
return { worktreePath, branchName, reused: true }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Проверяем, существует ли уже локальная ветка с таким именем
|
|
184
|
+
let branchExists = false
|
|
185
|
+
try {
|
|
186
|
+
await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir)
|
|
187
|
+
branchExists = true
|
|
188
|
+
} catch {
|
|
189
|
+
branchExists = false
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (branchExists) {
|
|
193
|
+
await gitRunner(['worktree', 'add', worktreePath, branchName], repoDir)
|
|
194
|
+
} else {
|
|
195
|
+
const baseRef = await determineBaseRef(repoDir, gitRunner)
|
|
196
|
+
await gitRunner(['worktree', 'add', '-b', branchName, worktreePath, baseRef], repoDir)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { worktreePath, branchName, created: true }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Проверить наличие незакоммиченных изменений в воркдереве.
|
|
204
|
+
*/
|
|
205
|
+
export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
|
|
206
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
207
|
+
return { dirty: false, files: [] }
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const res = await gitRunner(['status', '--porcelain'], worktreePath)
|
|
211
|
+
const files = res.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
|
212
|
+
return { dirty: files.length > 0, files }
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return { dirty: false, files: [], error: String(error?.message ?? error) }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Безопасно удалить воркдерево задачи.
|
|
220
|
+
*/
|
|
221
|
+
export async function removeTaskWorktree({
|
|
222
|
+
repoDir,
|
|
223
|
+
worktreePath,
|
|
224
|
+
branchName,
|
|
225
|
+
force = false,
|
|
226
|
+
deleteBranch = true,
|
|
227
|
+
gitRunner = defaultGitRunner,
|
|
228
|
+
}) {
|
|
229
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
230
|
+
return { removed: false, reason: 'not-found' }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
|
|
234
|
+
if (dirtyCheck.dirty && !force) {
|
|
235
|
+
return {
|
|
236
|
+
removed: false,
|
|
237
|
+
error: 'worktree-dirty',
|
|
238
|
+
files: dirtyCheck.files,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const removeArgs = force
|
|
243
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
244
|
+
: ['worktree', 'remove', worktreePath]
|
|
245
|
+
|
|
246
|
+
await gitRunner(removeArgs, repoDir)
|
|
247
|
+
try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune игнорирует ошибки */ }
|
|
248
|
+
|
|
249
|
+
let branchDeleted = false
|
|
250
|
+
if (deleteBranch && branchName && repoDir) {
|
|
251
|
+
try {
|
|
252
|
+
await gitRunner(['branch', '-d', branchName], repoDir)
|
|
253
|
+
branchDeleted = true
|
|
254
|
+
} catch {
|
|
255
|
+
// Игнорируем: ветка не удалена, если ещё не влита
|
|
256
|
+
branchDeleted = false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { removed: true, branchDeleted }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Зарегистрировать каталог воркдерева в DSH workspaceRegistry.
|
|
265
|
+
*/
|
|
266
|
+
export async function registerDshWorkspace({ workspaceRegistry, worktreePath, title }) {
|
|
267
|
+
if (!workspaceRegistry || typeof workspaceRegistry.create !== 'function') return undefined
|
|
268
|
+
try {
|
|
269
|
+
const entity = await workspaceRegistry.create(worktreePath, title || 'Task Workspace')
|
|
270
|
+
return entity?.id
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Дерегистрировать воркспейс из DSH workspaceRegistry.
|
|
278
|
+
*/
|
|
279
|
+
export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId }) {
|
|
280
|
+
if (!workspaceRegistry || typeof workspaceRegistry.delete !== 'function' || !workspaceId) return false
|
|
281
|
+
try {
|
|
282
|
+
return (await workspaceRegistry.delete(workspaceId)) === true
|
|
283
|
+
} catch {
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Выполнить очистку воркдерева задачи с проверкой незакоммиченных изменений.
|
|
290
|
+
*/
|
|
291
|
+
export async function cleanupTaskWorktree({
|
|
292
|
+
store,
|
|
293
|
+
task,
|
|
294
|
+
repoDir,
|
|
295
|
+
workspaceRegistry,
|
|
296
|
+
force = false,
|
|
297
|
+
deleteBranch = true,
|
|
298
|
+
gitRunner = defaultGitRunner,
|
|
299
|
+
}) {
|
|
300
|
+
const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
|
|
301
|
+
if (!current) return { error: 'task-not-found', status: 404 }
|
|
302
|
+
if (!current.worktree) return { noop: true, removed: false }
|
|
303
|
+
|
|
304
|
+
const targetRepoDir = repoDir || current.worktree
|
|
305
|
+
const result = await removeTaskWorktree({
|
|
306
|
+
repoDir: targetRepoDir,
|
|
307
|
+
worktreePath: current.worktree,
|
|
308
|
+
branchName: current.branch,
|
|
309
|
+
force,
|
|
310
|
+
deleteBranch,
|
|
311
|
+
gitRunner,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
if (result.error === 'worktree-dirty') {
|
|
315
|
+
store?.addTransition?.({
|
|
316
|
+
taskId: current.id,
|
|
317
|
+
fromCol: current.column,
|
|
318
|
+
toCol: current.column,
|
|
319
|
+
source: 'session',
|
|
320
|
+
detail: `очистка отложена: обнаружены незакоммиченные файлы: ${result.files.slice(0, 3).join(', ')}`,
|
|
321
|
+
})
|
|
322
|
+
return { error: 'worktree-dirty', files: result.files }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (workspaceRegistry && current.workspaceId) {
|
|
326
|
+
await deregisterDshWorkspace({ workspaceRegistry, workspaceId: current.workspaceId })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
store?.updateTask?.(current.id, { worktree: '', workspaceId: '' })
|
|
330
|
+
store?.addTransition?.({
|
|
331
|
+
taskId: current.id,
|
|
332
|
+
fromCol: current.column,
|
|
333
|
+
toCol: current.column,
|
|
334
|
+
source: 'session',
|
|
335
|
+
detail: 'очистка: воркдерево удалено' + (result.branchDeleted ? ', ветка удалена' : ''),
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
return { success: true, removed: true, branchDeleted: result.branchDeleted }
|
|
339
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
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",
|