@goodandready/dsh-kanban 0.2.16 → 0.2.18

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 CHANGED
@@ -222,7 +222,7 @@ dsh-kanban:
222
222
 
223
223
  ## 🧪 Testing & Verification
224
224
 
225
- Run the comprehensive 520+ unit and integration test suite:
225
+ Run the comprehensive 730+ unit and integration test suite:
226
226
 
227
227
  ```bash
228
228
  npm test
package/README.ru.md CHANGED
@@ -174,7 +174,7 @@ dsh-kanban:
174
174
 
175
175
  ## 🧪 Тестирование и верификация
176
176
 
177
- Запуск полного набора из более чем 720 юнит- и интеграционных тестов:
177
+ Запуск полного набора из более чем 730 юнит- и интеграционных тестов:
178
178
 
179
179
  ```bash
180
180
  npm test
package/lib/client.js CHANGED
@@ -210,6 +210,8 @@ window.__ModuleLoader__.load({
210
210
 
211
211
  function cardStatus(task) {
212
212
  const parts = []
213
+ if (task && task.workflowStatus === 'running') parts.push('⚡ Workflow')
214
+ else if (task && task.workflowStatus === 'failed') parts.push('⚡ Failed')
213
215
  if (task && typeof task.model === 'string' && task.model !== '') parts.push(task.model)
214
216
  if (task && typeof task.branch === 'string' && task.branch !== '') parts.push(task.branch)
215
217
  return parts.join(' · ')
@@ -1183,6 +1185,9 @@ window.__ModuleLoader__.load({
1183
1185
  'panel.worktree': 'Worktree: {path}',
1184
1186
  'panel.cleanupWorktree': 'Clean Worktree',
1185
1187
  'action.resume': '↻ Resume',
1188
+ 'action.cancelWorkflow': 'Cancel Workflow',
1189
+ 'panel.startWorkflow': '⚡ Workflow',
1190
+ 'panel.startWorkflowHint': 'Run multi-agent workflow via core workflow engine',
1186
1191
  'panel.worktreeDirty': 'Uncommitted changes in worktree: {files}',
1187
1192
  'panel.priority': 'Priority',
1188
1193
  'panel.priorityNone': 'No priority',
@@ -1571,6 +1576,9 @@ window.__ModuleLoader__.load({
1571
1576
  'panel.worktree': '工作区:{path}',
1572
1577
  'panel.cleanupWorktree': '清理工作区',
1573
1578
  'action.resume': '↻ 继续执行',
1579
+ 'action.cancelWorkflow': '取消工作流',
1580
+ 'panel.startWorkflow': '⚡ 工作流',
1581
+ 'panel.startWorkflowHint': '通过核心工作流引擎运行多代理工作流',
1574
1582
  'panel.worktreeDirty': '工作区中存在未提交的更改:{files}',
1575
1583
  'panel.priority': '优先级',
1576
1584
  'panel.priorityNone': '无优先级',
@@ -3835,6 +3843,52 @@ window.__ModuleLoader__.load({
3835
3843
  }
3836
3844
  },
3837
3845
  }, t('action.resume')) : null,
3846
+ openTask.workflowStatus === 'running' ? h('button', {
3847
+ type: 'button',
3848
+ className: 'dkb-discard',
3849
+ style: { marginRight: '8px' },
3850
+ disabled: starting,
3851
+ onClick: async () => {
3852
+ setStarting(true)
3853
+ try {
3854
+ await api('/task/' + encodeURIComponent(openTask.id) + '/workflow-cancel', {
3855
+ method: 'POST',
3856
+ body: JSON.stringify({ reason: 'cancelled-by-user' }),
3857
+ })
3858
+ setOpenTask(null)
3859
+ await load(board)
3860
+ if (props.onClose) props.onClose()
3861
+ } catch (e) {
3862
+ setError(t(e.key || 'error.unknown'))
3863
+ } finally {
3864
+ setStarting(false)
3865
+ }
3866
+ },
3867
+ }, t('action.cancelWorkflow')) : null,
3868
+ openTask.workflowStatus !== 'running' ? h('button', {
3869
+ type: 'button',
3870
+ className: 'dkb-secondary',
3871
+ style: { marginRight: '8px', border: '1px solid var(--dsw-alias-border-l2)' },
3872
+ title: t('panel.startWorkflowHint'),
3873
+ disabled: starting || model === '',
3874
+ onClick: async () => {
3875
+ setStarting(true)
3876
+ try {
3877
+ rememberLaunch(board, { provider, model, agentPreset, permission })
3878
+ await api('/task/' + encodeURIComponent(openTask.id) + '/workflow-start', {
3879
+ method: 'POST',
3880
+ body: JSON.stringify({ provider, model, text: draftText, agentPreset, permission }),
3881
+ })
3882
+ setOpenTask(null)
3883
+ await load(board)
3884
+ if (props.onClose) props.onClose()
3885
+ } catch (e) {
3886
+ setError(t(e.key || 'error.unknown'))
3887
+ } finally {
3888
+ setStarting(false)
3889
+ }
3890
+ },
3891
+ }, t('panel.startWorkflow')) : null,
3838
3892
  h('button', {
3839
3893
  type: 'button', className: 'dkb-save', disabled: starting || model === '',
3840
3894
  onClick: async () => {
package/lib/index.js CHANGED
@@ -55,12 +55,13 @@ import {
55
55
  runBatch, queueTask, unqueueTask, liveSessions, dispatchNextQueuedTask,
56
56
  PERMISSION_RANKS,
57
57
  } from './launcher.js'
58
- import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
58
+ import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree, defaultGitRunner } from './worktree.js'
59
59
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
60
60
  import { createGiteaClient } from './gitea.js'
61
61
  import { createGitHubClient } from './github.js'
62
62
  import { createGitProvider } from './git-provider.js'
63
63
  import { createKanbanTaskService } from './task-service.js'
64
+ import { WorkflowBridge } from './workflow-bridge.js'
64
65
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
65
66
  import { isWatched } from './intake.js'
66
67
  import { handleSessionEvent } from './lifecycle.js'
@@ -278,6 +279,23 @@ export function apply(ctx, config) {
278
279
  pid: process.pid,
279
280
  })
280
281
 
282
+ const workflowBridge = new WorkflowBridge({
283
+ get store() { return store },
284
+ get workflowEngine() { return ctx.get('workflowEngine') },
285
+ get agentPresets() { return ctx.get('agentPresets') },
286
+ get agents() { return ctx.agents },
287
+ git,
288
+ getConfig,
289
+ logger: ctx.logger,
290
+ eventHub,
291
+ })
292
+ const unbindCoreWorkflow = workflowBridge.bindCoreEvents(ctx)
293
+ if (typeof ctx.on === 'function') {
294
+ ctx.on('dispose', () => {
295
+ unbindCoreWorkflow?.()
296
+ })
297
+ }
298
+
281
299
  const syncPower = () => {
282
300
  try {
283
301
  const cfg = getConfig()
@@ -964,7 +982,31 @@ export function apply(ctx, config) {
964
982
  reply(res, await refreshTask({ gitea: git, store, id }))
965
983
  return
966
984
  }
967
- if (req.method === 'POST' && action === 'start') {
985
+ if (req.method === 'GET' && action === 'workflow-status') {
986
+ const task = store.getTask(id)
987
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
988
+ const active = workflowBridge.activeRuns.get(id)
989
+ reply(res, {
990
+ active: Boolean(active),
991
+ runId: active?.runId || task.workflowRunId || '',
992
+ role: active?.role || '',
993
+ status: task.workflowStatus || (active ? 'running' : 'idle'),
994
+ })
995
+ return
996
+ }
997
+ if (req.method === 'POST' && action === 'workflow-cancel') {
998
+ try {
999
+ const input = await readBody(req)
1000
+ const reason = typeof input?.reason === 'string' ? input.reason : 'cancelled-by-user'
1001
+ const result = await workflowBridge.cancelTaskWorkflow(id, reason)
1002
+ syncPower()
1003
+ reply(res, result)
1004
+ } catch (error) {
1005
+ writeJson(res, 500, { error: error?.message || 'cancel-failed' })
1006
+ }
1007
+ return
1008
+ }
1009
+ if (req.method === 'POST' && (action === 'start' || action === 'workflow-start')) {
968
1010
  try {
969
1011
  const input = await readBody(req)
970
1012
  const task = store.getTask(id)
@@ -974,6 +1016,27 @@ export function apply(ctx, config) {
974
1016
  fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
975
1017
  })
976
1018
  if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
1019
+
1020
+ if (action === 'workflow-start' || input.workflow === true || input.mode === 'workflow') {
1021
+ const started = await workflowBridge.startTaskWorkflow({
1022
+ task,
1023
+ role: typeof input.role === 'string' ? input.role : (typeof input.agentPreset === 'string' ? input.agentPreset : undefined),
1024
+ provider: picked.provider,
1025
+ model: picked.model,
1026
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1027
+ gitRunner: defaultGitRunner,
1028
+ workspaceRegistry: ctx.get('workspaceRegistry'),
1029
+ claimAs: await whoAmI(),
1030
+ permission: typeof input.permission === 'string' ? input.permission : undefined,
1031
+ permissions: ctx.get('permissionPresets'),
1032
+ cwdOf: rootOf(getConfig()),
1033
+ text: typeof input.text === 'string' ? input.text : undefined,
1034
+ })
1035
+ syncPower()
1036
+ reply(res, started)
1037
+ return
1038
+ }
1039
+
977
1040
  const started = await runTask({
978
1041
  agents: ctx.agents,
979
1042
  store,
@@ -999,7 +1062,7 @@ export function apply(ctx, config) {
999
1062
  ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
1000
1063
  // Отказ по уровню доступа называется своим именем: «не удалось» на
1001
1064
  // месте «сессия пошла бы с большими правами» — это молчание.
1002
- writeJson(res, error?.status ?? 500, { error: error?.key ?? 'start-failed' })
1065
+ writeJson(res, error?.status ?? 500, { error: error?.key ?? error?.message ?? 'start-failed' })
1003
1066
  }
1004
1067
  return
1005
1068
  }
package/lib/store.js CHANGED
@@ -73,6 +73,8 @@ CREATE TABLE IF NOT EXISTS tasks (
73
73
  rejectedAt INTEGER NOT NULL DEFAULT 0,
74
74
  parentId TEXT NOT NULL DEFAULT '',
75
75
  isEpic INTEGER NOT NULL DEFAULT 0,
76
+ workflowRunId TEXT NOT NULL DEFAULT '',
77
+ workflowStatus TEXT NOT NULL DEFAULT '',
76
78
  updatedAt INTEGER NOT NULL
77
79
  );
78
80
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -103,10 +105,11 @@ const WRITABLE = [
103
105
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
104
106
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
105
107
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
108
+ 'workflowRunId', 'workflowStatus',
106
109
  ]
107
110
 
108
111
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
109
- const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool'])
112
+ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow'])
110
113
 
111
114
  function rowToTask(row) {
112
115
  if (row === undefined) return undefined
@@ -241,6 +244,8 @@ export function openStore({ dir }) {
241
244
  ['isEpic', "INTEGER NOT NULL DEFAULT 0"],
242
245
  ['externalRef', "TEXT"],
243
246
  ['provider', "TEXT NOT NULL DEFAULT 'gitea'"],
247
+ ['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
248
+ ['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
244
249
  ]) {
245
250
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
246
251
  }
@@ -271,8 +276,9 @@ export function openStore({ dir }) {
271
276
  labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
272
277
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
273
278
  cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
274
- checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
275
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
279
+ checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
280
+ workflowRunId, workflowStatus)
281
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
276
282
  boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
277
283
  insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
278
284
  lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
@@ -472,6 +478,8 @@ export function openStore({ dir }) {
472
478
  typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
473
479
  input.parentId ?? '',
474
480
  input.isEpic ? 1 : 0,
481
+ input.workflowRunId ?? '',
482
+ input.workflowStatus ?? '',
475
483
  )
476
484
  return rowToTask(q.taskById.get(id))
477
485
  },
@@ -0,0 +1,567 @@
1
+ // Мост между карточками Kanban и встроенным в ядро DSH workflowEngine.
2
+ //
3
+ // Выполняет перенос логики из dsh-agent-orchestrator/lib/integrations/kanban-bridge.js:
4
+ // 1. Сопоставление карточка ↔ роль (по меткам и колонкам).
5
+ // 2. Выбор пресета агента с проверкой доступности через ctx.agentPresets.list()
6
+ // и фоллбэком на пресет по умолчанию.
7
+ // 3. Формирование сценария и запуск через ctx.workflowEngine.start().
8
+ // 4. Отслеживание жизненного цикла workflow/* (agent-start, agent-end, end):
9
+ // - running -> review/done/failed;
10
+ // - фиксация runId и сессии в задаче;
11
+ // - отправка summary в связанный issue Gitea / GitHub.
12
+ // 5. Отмена активного workflow по запросу из карточки.
13
+
14
+ import { buildStartMessage, obtainAgent } from './launcher.js'
15
+ import { applyMove } from './routes.js'
16
+ import { defaultGitRunner } from './worktree.js'
17
+
18
+ export const DEFAULT_COLUMN_ROLES = {
19
+ backlog: 'spec',
20
+ ready: 'spec',
21
+ progress: 'backend',
22
+ review: 'qa_tests',
23
+ done: 'docs',
24
+ }
25
+
26
+ export const LABEL_ROLE_PATTERNS = [
27
+ { role: 'spec', patterns: ['spec', 'specification', 'requirements', 'requirements-analysis', 'анализ', 'тз'] },
28
+ { role: 'architecture', patterns: ['architecture', 'архитектура', 'contract'] },
29
+ { role: 'ui_design', patterns: ['ui', 'ui_design', 'design', 'ux', 'дизайн'] },
30
+ { role: 'frontend', patterns: ['frontend', 'web', 'client', 'ui-impl', 'фронтенд'] },
31
+ { role: 'backend', patterns: ['backend', 'api', 'server', 'бэкенд'] },
32
+ { role: 'fullstack', patterns: ['fullstack', 'full-stack', 'e2e'] },
33
+ { role: 'qa_tests', patterns: ['qa', 'qa_tests', 'test', 'tests', 'testing', 'verification', 'тест', 'тесты'] },
34
+ { role: 'bugfix', patterns: ['bug', 'bugfix', 'fix', 'баг', 'фикс'] },
35
+ { role: 'docs', patterns: ['docs', 'doc', 'documentation', 'документация'] },
36
+ { role: 'refactoring', patterns: ['refactor', 'refactoring', 'рефакторинг'] },
37
+ { role: 'research', patterns: ['research', 'spike', 'исследование'] },
38
+ { role: 'devops', patterns: ['devops', 'infra', 'ci', 'cd', 'deploy', 'деплой'] },
39
+ ]
40
+
41
+ /**
42
+ * Определить роль для задачи по меткам, колонке или явному полю role.
43
+ *
44
+ * @param {object} task Задача доски
45
+ * @returns {string} Идентификатор роли
46
+ */
47
+ export function resolveRole(task) {
48
+ if (!task) return 'backend'
49
+
50
+ if (typeof task.role === 'string' && task.role.trim() !== '') {
51
+ return task.role.trim()
52
+ }
53
+
54
+ let labels = []
55
+ if (Array.isArray(task.labels)) {
56
+ labels = task.labels
57
+ } else if (typeof task.labels === 'string') {
58
+ try {
59
+ const parsed = JSON.parse(task.labels)
60
+ if (Array.isArray(parsed)) labels = parsed
61
+ } catch {
62
+ labels = task.labels.split(',').map((s) => s.trim()).filter(Boolean)
63
+ }
64
+ }
65
+
66
+ for (const label of labels) {
67
+ const l = String(label).toLowerCase()
68
+ for (const item of LABEL_ROLE_PATTERNS) {
69
+ if (item.patterns.some((p) => l === p || l.includes(p))) {
70
+ return item.role
71
+ }
72
+ }
73
+ }
74
+
75
+ const col = String(task.col || task.column || '').toLowerCase()
76
+ if (col && DEFAULT_COLUMN_ROLES[col]) {
77
+ return DEFAULT_COLUMN_ROLES[col]
78
+ }
79
+
80
+ return 'backend'
81
+ }
82
+
83
+ /**
84
+ * Подобрать пресет агента из доступных.
85
+ * Если роль найдена в списке доступных пресетов — возвращает её.
86
+ * Иначе выполняет безопасную деградацию на defaultPreset или undefined.
87
+ *
88
+ * @param {object} params
89
+ * @param {string} params.role Имя роли
90
+ * @param {Array<object|string>} [params.availablePresets] Список пресетов
91
+ * @param {string} [params.defaultPreset] Пресет по умолчанию
92
+ * @returns {string|undefined}
93
+ */
94
+ export function resolvePreset({ role, availablePresets = [], defaultPreset = undefined }) {
95
+ if (!role) return defaultPreset
96
+
97
+ const presetIds = new Set(
98
+ (Array.isArray(availablePresets) ? availablePresets : []).map((p) =>
99
+ typeof p === 'string' ? p : p?.id
100
+ ).filter(Boolean)
101
+ )
102
+
103
+ if (presetIds.has(role)) {
104
+ return role
105
+ }
106
+
107
+ if (defaultPreset && presetIds.has(defaultPreset)) {
108
+ return defaultPreset
109
+ }
110
+
111
+ return undefined
112
+ }
113
+
114
+ /**
115
+ * Построить JavaScript сценарий для ctx.workflowEngine.start().
116
+ *
117
+ * @param {object} params
118
+ * @param {object} params.task
119
+ * @param {string} params.role
120
+ * @returns {string}
121
+ */
122
+ export function buildWorkflowScript({ task, role }) {
123
+ const taskId = String(task?.id || 'unknown').replace(/["\\]/g, '')
124
+ const safeRole = String(role || 'backend').replace(/["\\]/g, '')
125
+ const issueNum = task?.issueNumber ? `#${task.issueNumber}` : taskId
126
+
127
+ return [
128
+ `phase("${safeRole}");`,
129
+ `log("Запуск этапа ${safeRole} по карточке ${issueNum}");`,
130
+ `const prompt = args.prompt;`,
131
+ `const result = await agent(prompt, { label: "${safeRole}", phase: "${safeRole}" });`,
132
+ `return {`,
133
+ ` role: "${safeRole}",`,
134
+ ` taskId: "${taskId}",`,
135
+ ` summary: typeof result === 'string' ? result : (result ? JSON.stringify(result) : '')`,
136
+ `};`,
137
+ ].join('\n')
138
+ }
139
+
140
+ /**
141
+ * Сформировать метаданные сценария workflow.
142
+ *
143
+ * @param {object} params
144
+ * @param {object} params.task
145
+ * @param {string} params.role
146
+ * @returns {object}
147
+ */
148
+ export function buildWorkflowMeta({ task, role }) {
149
+ const shortId = String(task?.id || 'run').slice(0, 8)
150
+ const title = String(task?.title || 'task').slice(0, 60)
151
+ const safeRole = String(role || 'backend')
152
+
153
+ return {
154
+ name: `kanban-${shortId}`,
155
+ description: `Workflow run for task #${task?.issueNumber || shortId} (${safeRole}): ${title}`,
156
+ phases: [
157
+ { title: safeRole, detail: `Execute ${safeRole} role for task` },
158
+ ],
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Определить целевую колонку после успешного завершения этапа workflow.
164
+ *
165
+ * @param {string} currentCol
166
+ * @returns {string}
167
+ */
168
+ export function resolveTargetColumn(currentCol) {
169
+ const col = String(currentCol || '').toLowerCase()
170
+ if (col === 'backlog' || col === 'ready' || col === 'progress') {
171
+ return 'review'
172
+ }
173
+ if (col === 'review') {
174
+ return 'done'
175
+ }
176
+ return col || 'review'
177
+ }
178
+
179
+ /**
180
+ * Класс-мост между доской и встроенным сервисом workflow ядра DSH.
181
+ */
182
+ export class WorkflowBridge {
183
+ constructor({
184
+ store,
185
+ workflowEngine,
186
+ agentPresets,
187
+ agents,
188
+ git,
189
+ getConfig = () => ({}),
190
+ logger,
191
+ eventHub,
192
+ } = {}) {
193
+ this.store = store
194
+ this.workflowEngine = workflowEngine
195
+ this.agentPresets = agentPresets
196
+ this.agents = agents
197
+ this.git = git
198
+ this.getConfig = getConfig
199
+ this.logger = logger
200
+ this.eventHub = eventHub
201
+
202
+ // taskId -> { run, runId, taskId, role, preset, parentAgent, task }
203
+ this.activeRuns = new Map()
204
+ // runId -> taskId
205
+ this.runToTask = new Map()
206
+ }
207
+
208
+ /**
209
+ * Список зарегистрированных пресетов ядра.
210
+ */
211
+ async getAvailablePresets() {
212
+ if (!this.agentPresets || typeof this.agentPresets.list !== 'function') {
213
+ return []
214
+ }
215
+ try {
216
+ return await this.agentPresets.list()
217
+ } catch (err) {
218
+ this.logger?.warn?.(`dsh-kanban: не удалось получить список пресетов: ${err.message}`)
219
+ return []
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Запуск workflow по карточке.
225
+ */
226
+ async startTaskWorkflow({
227
+ task,
228
+ role: explicitRole,
229
+ provider,
230
+ model,
231
+ mintSessionId,
232
+ gitRunner = defaultGitRunner,
233
+ workspaceRegistry,
234
+ claimAs,
235
+ permission,
236
+ permissions,
237
+ cwdOf,
238
+ text,
239
+ }) {
240
+ if (!task || !task.id) throw new Error('task is required')
241
+
242
+ if (!this.workflowEngine || typeof this.workflowEngine.start !== 'function') {
243
+ const err = new Error('workflow engine is not available in core')
244
+ err.key = 'workflow-engine-unavailable'
245
+ err.status = 503
246
+ throw err
247
+ }
248
+
249
+ if (this.activeRuns.has(task.id)) {
250
+ const existing = this.activeRuns.get(task.id)
251
+ return {
252
+ mode: 'workflow',
253
+ alreadyRunning: true,
254
+ runId: existing.runId,
255
+ taskId: task.id,
256
+ role: existing.role,
257
+ }
258
+ }
259
+
260
+ const role = explicitRole || resolveRole(task)
261
+ const availablePresets = await this.getAvailablePresets()
262
+ const preset = resolvePreset({
263
+ role,
264
+ availablePresets,
265
+ defaultPreset: this.getConfig()?.defaultAgentPreset || undefined,
266
+ })
267
+
268
+ const config = this.getConfig()
269
+ const taken = await obtainAgent({
270
+ agents: this.agents,
271
+ task,
272
+ config,
273
+ provider,
274
+ model,
275
+ mintSessionId,
276
+ cwdOf,
277
+ logger: this.logger,
278
+ agentPreset: preset,
279
+ permission,
280
+ permissions,
281
+ gitRunner,
282
+ workspaceRegistry,
283
+ })
284
+
285
+ const promptText = typeof text === 'string' && text.trim() !== ''
286
+ ? text.trim()
287
+ : buildStartMessage(task, config)
288
+
289
+ const script = buildWorkflowScript({ task, role })
290
+ const meta = buildWorkflowMeta({ task, role })
291
+
292
+ let run
293
+ try {
294
+ run = this.workflowEngine.start({
295
+ script,
296
+ meta,
297
+ args: { prompt: promptText },
298
+ parent: taken.agent,
299
+ })
300
+ } catch (err) {
301
+ this.logger?.warn?.(`dsh-kanban: workflowEngine.start failed: ${err.message}`)
302
+ throw err
303
+ }
304
+
305
+ const runId = String(run.id)
306
+ const entry = {
307
+ run,
308
+ runId,
309
+ taskId: task.id,
310
+ role,
311
+ preset,
312
+ parentAgent: taken.agent,
313
+ task,
314
+ startTime: Date.now(),
315
+ }
316
+
317
+ this.activeRuns.set(task.id, entry)
318
+ this.runToTask.set(runId, task.id)
319
+
320
+ // Обновляем карточку в базе данных
321
+ this.store.updateTask(task.id, {
322
+ workflowRunId: runId,
323
+ workflowStatus: 'running',
324
+ sessionId: taken.sessionId,
325
+ provider,
326
+ model,
327
+ ...(permission ? { permission } : {}),
328
+ ...(taken.worktree ? { worktree: taken.worktree } : {}),
329
+ ...(taken.branch ? { branch: taken.branch } : {}),
330
+ ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
331
+ })
332
+
333
+ this.store.addTransition({
334
+ taskId: task.id,
335
+ fromCol: task.col || task.column,
336
+ toCol: task.col || task.column,
337
+ source: 'workflow',
338
+ detail: `запуск workflow ${runId} (роль: ${role}, пресет: ${preset || 'default'})`,
339
+ })
340
+
341
+ this.eventHub?.broadcast?.('task:change', {
342
+ taskId: task.id,
343
+ action: 'workflow-started',
344
+ runId,
345
+ role,
346
+ preset,
347
+ sessionId: taken.sessionId,
348
+ })
349
+
350
+ // Асинхронное ожидание завершения run
351
+ run.result.then(async (result) => {
352
+ await this.handleWorkflowResult(task.id, runId, role, result)
353
+ }).catch(async (err) => {
354
+ this.logger?.warn?.(`dsh-kanban: workflow run ${runId} rejected: ${err?.message}`)
355
+ await this.handleWorkflowResult(task.id, runId, role, {
356
+ stopReason: 'error',
357
+ error: err?.message || 'unknown error',
358
+ })
359
+ })
360
+
361
+ return {
362
+ mode: 'workflow',
363
+ started: true,
364
+ runId,
365
+ taskId: task.id,
366
+ role,
367
+ preset: preset || 'default',
368
+ sessionId: taken.sessionId,
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Отмена запущенного workflow.
374
+ */
375
+ async cancelTaskWorkflow(taskId, reason = 'cancelled-by-user') {
376
+ if (!taskId) return { cancelled: false, error: 'task-id-required' }
377
+
378
+ const entry = this.activeRuns.get(taskId)
379
+ if (!entry) {
380
+ // Возможно, статус в БД ещё числится running
381
+ const task = this.store.getTask(taskId)
382
+ if (task?.workflowStatus === 'running') {
383
+ this.store.updateTask(taskId, { workflowStatus: 'cancelled' })
384
+ return { cancelled: true, note: 'marked-cancelled-in-store' }
385
+ }
386
+ return { cancelled: false, error: 'no-active-workflow' }
387
+ }
388
+
389
+ try {
390
+ entry.run.cancel(reason)
391
+ } catch (err) {
392
+ this.logger?.warn?.(`dsh-kanban: error canceling workflow run ${entry.runId}: ${err.message}`)
393
+ }
394
+
395
+ this.store.updateTask(taskId, { workflowStatus: 'cancelled' })
396
+ this.store.addTransition({
397
+ taskId,
398
+ fromCol: entry.task?.col || entry.task?.column || 'progress',
399
+ toCol: entry.task?.col || entry.task?.column || 'progress',
400
+ source: 'workflow',
401
+ detail: `отмена workflow ${entry.runId}: ${reason}`,
402
+ })
403
+
404
+ this.eventHub?.broadcast?.('task:change', {
405
+ taskId,
406
+ action: 'workflow-cancelled',
407
+ runId: entry.runId,
408
+ reason,
409
+ })
410
+
411
+ return { cancelled: true, runId: entry.runId }
412
+ }
413
+
414
+ /**
415
+ * Обработка завершения выполнения workflow.
416
+ */
417
+ async handleWorkflowResult(taskId, runId, role, result) {
418
+ const entry = this.activeRuns.get(taskId)
419
+ const task = this.store.getTask(taskId) || entry?.task
420
+
421
+ this.activeRuns.delete(taskId)
422
+ this.runToTask.delete(runId)
423
+
424
+ if (!task) return
425
+
426
+ const stopReason = result?.stopReason || 'error'
427
+ const isCompleted = stopReason === 'completed'
428
+ const summary = typeof result?.value?.summary === 'string'
429
+ ? result.value.summary
430
+ : (result?.error || '')
431
+
432
+ const newStatus = isCompleted ? 'completed' : (stopReason === 'cancelled' ? 'cancelled' : 'failed')
433
+ this.store.updateTask(taskId, {
434
+ workflowStatus: newStatus,
435
+ })
436
+
437
+ if (isCompleted) {
438
+ const targetCol = resolveTargetColumn(task.col || task.column)
439
+ if (targetCol !== (task.col || task.column)) {
440
+ applyMove({
441
+ store: this.store,
442
+ config: this.getConfig(),
443
+ id: taskId,
444
+ column: targetCol,
445
+ source: 'workflow',
446
+ detail: `workflow ${runId} завершён успешно (роль: ${role})`,
447
+ })
448
+ }
449
+
450
+ // Отправляем комментарий в связанный issue Gitea / GitHub
451
+ if (this.git && task.repo && task.issueNumber) {
452
+ const commentBody = [
453
+ `🚀 **Workflow завершён успешно!** (роль: \`${role}\`)`,
454
+ summary ? `\n${summary}` : '',
455
+ `\n*Статус карточки переведён в колонку "${targetCol}".*`,
456
+ ].filter(Boolean).join('\n')
457
+
458
+ try {
459
+ await this.git.comment({
460
+ owner: task.owner,
461
+ repo: task.repo,
462
+ index: task.issueNumber,
463
+ body: commentBody,
464
+ provider: task.provider,
465
+ })
466
+ } catch (err) {
467
+ this.logger?.warn?.(`dsh-kanban: не удалось отправить комментарий в issue ${task.repo}#${task.issueNumber}: ${err.message}`)
468
+ }
469
+ }
470
+
471
+ // Добавляем комментарий в карточку
472
+ if (typeof this.store.addTaskComment === 'function' && summary) {
473
+ this.store.addTaskComment(taskId, {
474
+ author: 'workflow',
475
+ body: `🚀 **Workflow завершён (${role}):**\n\n${summary}`,
476
+ })
477
+ }
478
+ } else {
479
+ this.store.addTransition({
480
+ taskId,
481
+ fromCol: task.col || task.column,
482
+ toCol: task.col || task.column,
483
+ source: 'workflow',
484
+ detail: `workflow ${runId} завершён со статусом ${stopReason}: ${result?.error || ''}`,
485
+ })
486
+ }
487
+
488
+ if (entry?.run && typeof entry.run.dispose === 'function') {
489
+ try {
490
+ await entry.run.dispose()
491
+ } catch (err) {
492
+ this.logger?.warn?.(`dsh-kanban: ошибка при dispose workflow run ${runId}: ${err.message}`)
493
+ }
494
+ }
495
+
496
+ this.eventHub?.broadcast?.('task:change', {
497
+ taskId,
498
+ action: 'workflow-ended',
499
+ runId,
500
+ role,
501
+ status: newStatus,
502
+ stopReason,
503
+ })
504
+ }
505
+
506
+ /**
507
+ * Подключить слушатели событий ядра workflow/*.
508
+ *
509
+ * @param {object} ctx Cordis context
510
+ */
511
+ bindCoreEvents(ctx) {
512
+ if (!ctx || typeof ctx.on !== 'function') return () => {}
513
+
514
+ const unbind = [
515
+ ctx.on('workflow/agent-start', (runInfo, agentInfo) => {
516
+ const runId = String(runInfo?.id || '')
517
+ const taskId = this.runToTask.get(runId)
518
+ if (!taskId) return
519
+
520
+ if (agentInfo?.childId) {
521
+ this.store.updateTask(taskId, { sessionId: String(agentInfo.childId) })
522
+ this.eventHub?.broadcast?.('task:change', {
523
+ taskId,
524
+ action: 'workflow-agent-start',
525
+ runId,
526
+ sessionId: String(agentInfo.childId),
527
+ label: agentInfo.label,
528
+ })
529
+ }
530
+ }),
531
+
532
+ ctx.on('workflow/agent-end', (runInfo, endInfo) => {
533
+ const runId = String(runInfo?.id || '')
534
+ const taskId = this.runToTask.get(runId)
535
+ if (!taskId) return
536
+
537
+ this.eventHub?.broadcast?.('task:change', {
538
+ taskId,
539
+ action: 'workflow-agent-end',
540
+ runId,
541
+ outcome: endInfo?.outcome,
542
+ })
543
+ }),
544
+
545
+ ctx.on('workflow/end', (runInfo, resultInfo) => {
546
+ const runId = String(runInfo?.id || '')
547
+ const taskId = this.runToTask.get(runId)
548
+ if (!taskId) return
549
+
550
+ // handleWorkflowResult также вызывается через run.result.then,
551
+ // но событие workflow/end гарантирует синхронизацию наблюдателей
552
+ this.eventHub?.broadcast?.('task:change', {
553
+ taskId,
554
+ action: 'workflow-core-end',
555
+ runId,
556
+ stopReason: resultInfo?.stopReason,
557
+ })
558
+ }),
559
+ ]
560
+
561
+ return () => {
562
+ for (const fn of unbind) {
563
+ if (typeof fn === 'function') fn()
564
+ }
565
+ }
566
+ }
567
+ }
package/lib/worktree.js CHANGED
@@ -23,7 +23,7 @@ const execFileAsync = promisify(execFile)
23
23
  * Стандартный исполнитель git-команд.
24
24
  * Принимает аргументы массивом, исключая шелл-инъекции.
25
25
  */
26
- async function defaultGitRunner(args, cwd) {
26
+ export async function defaultGitRunner(args, cwd) {
27
27
  try {
28
28
  const { stdout, stderr } = await execFileAsync('git', args, {
29
29
  cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",