@goodandready/dsh-kanban 0.2.16 → 0.2.17

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
@@ -61,6 +61,7 @@ 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,
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,566 @@
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
+
17
+ export const DEFAULT_COLUMN_ROLES = {
18
+ backlog: 'spec',
19
+ ready: 'spec',
20
+ progress: 'backend',
21
+ review: 'qa_tests',
22
+ done: 'docs',
23
+ }
24
+
25
+ export const LABEL_ROLE_PATTERNS = [
26
+ { role: 'spec', patterns: ['spec', 'specification', 'requirements', 'requirements-analysis', 'анализ', 'тз'] },
27
+ { role: 'architecture', patterns: ['architecture', 'архитектура', 'contract'] },
28
+ { role: 'ui_design', patterns: ['ui', 'ui_design', 'design', 'ux', 'дизайн'] },
29
+ { role: 'frontend', patterns: ['frontend', 'web', 'client', 'ui-impl', 'фронтенд'] },
30
+ { role: 'backend', patterns: ['backend', 'api', 'server', 'бэкенд'] },
31
+ { role: 'fullstack', patterns: ['fullstack', 'full-stack', 'e2e'] },
32
+ { role: 'qa_tests', patterns: ['qa', 'qa_tests', 'test', 'tests', 'testing', 'verification', 'тест', 'тесты'] },
33
+ { role: 'bugfix', patterns: ['bug', 'bugfix', 'fix', 'баг', 'фикс'] },
34
+ { role: 'docs', patterns: ['docs', 'doc', 'documentation', 'документация'] },
35
+ { role: 'refactoring', patterns: ['refactor', 'refactoring', 'рефакторинг'] },
36
+ { role: 'research', patterns: ['research', 'spike', 'исследование'] },
37
+ { role: 'devops', patterns: ['devops', 'infra', 'ci', 'cd', 'deploy', 'деплой'] },
38
+ ]
39
+
40
+ /**
41
+ * Определить роль для задачи по меткам, колонке или явному полю role.
42
+ *
43
+ * @param {object} task Задача доски
44
+ * @returns {string} Идентификатор роли
45
+ */
46
+ export function resolveRole(task) {
47
+ if (!task) return 'backend'
48
+
49
+ if (typeof task.role === 'string' && task.role.trim() !== '') {
50
+ return task.role.trim()
51
+ }
52
+
53
+ let labels = []
54
+ if (Array.isArray(task.labels)) {
55
+ labels = task.labels
56
+ } else if (typeof task.labels === 'string') {
57
+ try {
58
+ const parsed = JSON.parse(task.labels)
59
+ if (Array.isArray(parsed)) labels = parsed
60
+ } catch {
61
+ labels = task.labels.split(',').map((s) => s.trim()).filter(Boolean)
62
+ }
63
+ }
64
+
65
+ for (const label of labels) {
66
+ const l = String(label).toLowerCase()
67
+ for (const item of LABEL_ROLE_PATTERNS) {
68
+ if (item.patterns.some((p) => l === p || l.includes(p))) {
69
+ return item.role
70
+ }
71
+ }
72
+ }
73
+
74
+ const col = String(task.col || task.column || '').toLowerCase()
75
+ if (col && DEFAULT_COLUMN_ROLES[col]) {
76
+ return DEFAULT_COLUMN_ROLES[col]
77
+ }
78
+
79
+ return 'backend'
80
+ }
81
+
82
+ /**
83
+ * Подобрать пресет агента из доступных.
84
+ * Если роль найдена в списке доступных пресетов — возвращает её.
85
+ * Иначе выполняет безопасную деградацию на defaultPreset или undefined.
86
+ *
87
+ * @param {object} params
88
+ * @param {string} params.role Имя роли
89
+ * @param {Array<object|string>} [params.availablePresets] Список пресетов
90
+ * @param {string} [params.defaultPreset] Пресет по умолчанию
91
+ * @returns {string|undefined}
92
+ */
93
+ export function resolvePreset({ role, availablePresets = [], defaultPreset = undefined }) {
94
+ if (!role) return defaultPreset
95
+
96
+ const presetIds = new Set(
97
+ (Array.isArray(availablePresets) ? availablePresets : []).map((p) =>
98
+ typeof p === 'string' ? p : p?.id
99
+ ).filter(Boolean)
100
+ )
101
+
102
+ if (presetIds.has(role)) {
103
+ return role
104
+ }
105
+
106
+ if (defaultPreset && presetIds.has(defaultPreset)) {
107
+ return defaultPreset
108
+ }
109
+
110
+ return undefined
111
+ }
112
+
113
+ /**
114
+ * Построить JavaScript сценарий для ctx.workflowEngine.start().
115
+ *
116
+ * @param {object} params
117
+ * @param {object} params.task
118
+ * @param {string} params.role
119
+ * @returns {string}
120
+ */
121
+ export function buildWorkflowScript({ task, role }) {
122
+ const taskId = String(task?.id || 'unknown').replace(/["\\]/g, '')
123
+ const safeRole = String(role || 'backend').replace(/["\\]/g, '')
124
+ const issueNum = task?.issueNumber ? `#${task.issueNumber}` : taskId
125
+
126
+ return [
127
+ `phase("${safeRole}");`,
128
+ `log("Запуск этапа ${safeRole} по карточке ${issueNum}");`,
129
+ `const prompt = args.prompt;`,
130
+ `const result = await agent(prompt, { label: "${safeRole}", phase: "${safeRole}" });`,
131
+ `return {`,
132
+ ` role: "${safeRole}",`,
133
+ ` taskId: "${taskId}",`,
134
+ ` summary: typeof result === 'string' ? result : (result ? JSON.stringify(result) : '')`,
135
+ `};`,
136
+ ].join('\n')
137
+ }
138
+
139
+ /**
140
+ * Сформировать метаданные сценария workflow.
141
+ *
142
+ * @param {object} params
143
+ * @param {object} params.task
144
+ * @param {string} params.role
145
+ * @returns {object}
146
+ */
147
+ export function buildWorkflowMeta({ task, role }) {
148
+ const shortId = String(task?.id || 'run').slice(0, 8)
149
+ const title = String(task?.title || 'task').slice(0, 60)
150
+ const safeRole = String(role || 'backend')
151
+
152
+ return {
153
+ name: `kanban-${shortId}`,
154
+ description: `Workflow run for task #${task?.issueNumber || shortId} (${safeRole}): ${title}`,
155
+ phases: [
156
+ { title: safeRole, detail: `Execute ${safeRole} role for task` },
157
+ ],
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Определить целевую колонку после успешного завершения этапа workflow.
163
+ *
164
+ * @param {string} currentCol
165
+ * @returns {string}
166
+ */
167
+ export function resolveTargetColumn(currentCol) {
168
+ const col = String(currentCol || '').toLowerCase()
169
+ if (col === 'backlog' || col === 'ready' || col === 'progress') {
170
+ return 'review'
171
+ }
172
+ if (col === 'review') {
173
+ return 'done'
174
+ }
175
+ return col || 'review'
176
+ }
177
+
178
+ /**
179
+ * Класс-мост между доской и встроенным сервисом workflow ядра DSH.
180
+ */
181
+ export class WorkflowBridge {
182
+ constructor({
183
+ store,
184
+ workflowEngine,
185
+ agentPresets,
186
+ agents,
187
+ git,
188
+ getConfig = () => ({}),
189
+ logger,
190
+ eventHub,
191
+ } = {}) {
192
+ this.store = store
193
+ this.workflowEngine = workflowEngine
194
+ this.agentPresets = agentPresets
195
+ this.agents = agents
196
+ this.git = git
197
+ this.getConfig = getConfig
198
+ this.logger = logger
199
+ this.eventHub = eventHub
200
+
201
+ // taskId -> { run, runId, taskId, role, preset, parentAgent, task }
202
+ this.activeRuns = new Map()
203
+ // runId -> taskId
204
+ this.runToTask = new Map()
205
+ }
206
+
207
+ /**
208
+ * Список зарегистрированных пресетов ядра.
209
+ */
210
+ async getAvailablePresets() {
211
+ if (!this.agentPresets || typeof this.agentPresets.list !== 'function') {
212
+ return []
213
+ }
214
+ try {
215
+ return await this.agentPresets.list()
216
+ } catch (err) {
217
+ this.logger?.warn?.(`dsh-kanban: не удалось получить список пресетов: ${err.message}`)
218
+ return []
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Запуск workflow по карточке.
224
+ */
225
+ async startTaskWorkflow({
226
+ task,
227
+ role: explicitRole,
228
+ provider,
229
+ model,
230
+ mintSessionId,
231
+ gitRunner,
232
+ workspaceRegistry,
233
+ claimAs,
234
+ permission,
235
+ permissions,
236
+ cwdOf,
237
+ text,
238
+ }) {
239
+ if (!task || !task.id) throw new Error('task is required')
240
+
241
+ if (!this.workflowEngine || typeof this.workflowEngine.start !== 'function') {
242
+ const err = new Error('workflow engine is not available in core')
243
+ err.key = 'workflow-engine-unavailable'
244
+ err.status = 503
245
+ throw err
246
+ }
247
+
248
+ if (this.activeRuns.has(task.id)) {
249
+ const existing = this.activeRuns.get(task.id)
250
+ return {
251
+ mode: 'workflow',
252
+ alreadyRunning: true,
253
+ runId: existing.runId,
254
+ taskId: task.id,
255
+ role: existing.role,
256
+ }
257
+ }
258
+
259
+ const role = explicitRole || resolveRole(task)
260
+ const availablePresets = await this.getAvailablePresets()
261
+ const preset = resolvePreset({
262
+ role,
263
+ availablePresets,
264
+ defaultPreset: this.getConfig()?.defaultAgentPreset || undefined,
265
+ })
266
+
267
+ const config = this.getConfig()
268
+ const taken = await obtainAgent({
269
+ agents: this.agents,
270
+ task,
271
+ config,
272
+ provider,
273
+ model,
274
+ mintSessionId,
275
+ cwdOf,
276
+ logger: this.logger,
277
+ agentPreset: preset,
278
+ permission,
279
+ permissions,
280
+ gitRunner,
281
+ workspaceRegistry,
282
+ })
283
+
284
+ const promptText = typeof text === 'string' && text.trim() !== ''
285
+ ? text.trim()
286
+ : buildStartMessage(task, config)
287
+
288
+ const script = buildWorkflowScript({ task, role })
289
+ const meta = buildWorkflowMeta({ task, role })
290
+
291
+ let run
292
+ try {
293
+ run = this.workflowEngine.start({
294
+ script,
295
+ meta,
296
+ args: { prompt: promptText },
297
+ parent: taken.agent,
298
+ })
299
+ } catch (err) {
300
+ this.logger?.warn?.(`dsh-kanban: workflowEngine.start failed: ${err.message}`)
301
+ throw err
302
+ }
303
+
304
+ const runId = String(run.id)
305
+ const entry = {
306
+ run,
307
+ runId,
308
+ taskId: task.id,
309
+ role,
310
+ preset,
311
+ parentAgent: taken.agent,
312
+ task,
313
+ startTime: Date.now(),
314
+ }
315
+
316
+ this.activeRuns.set(task.id, entry)
317
+ this.runToTask.set(runId, task.id)
318
+
319
+ // Обновляем карточку в базе данных
320
+ this.store.updateTask(task.id, {
321
+ workflowRunId: runId,
322
+ workflowStatus: 'running',
323
+ sessionId: taken.sessionId,
324
+ provider,
325
+ model,
326
+ ...(permission ? { permission } : {}),
327
+ ...(taken.worktree ? { worktree: taken.worktree } : {}),
328
+ ...(taken.branch ? { branch: taken.branch } : {}),
329
+ ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
330
+ })
331
+
332
+ this.store.addTransition({
333
+ taskId: task.id,
334
+ fromCol: task.col || task.column,
335
+ toCol: task.col || task.column,
336
+ source: 'workflow',
337
+ detail: `запуск workflow ${runId} (роль: ${role}, пресет: ${preset || 'default'})`,
338
+ })
339
+
340
+ this.eventHub?.broadcast?.('task:change', {
341
+ taskId: task.id,
342
+ action: 'workflow-started',
343
+ runId,
344
+ role,
345
+ preset,
346
+ sessionId: taken.sessionId,
347
+ })
348
+
349
+ // Асинхронное ожидание завершения run
350
+ run.result.then(async (result) => {
351
+ await this.handleWorkflowResult(task.id, runId, role, result)
352
+ }).catch(async (err) => {
353
+ this.logger?.warn?.(`dsh-kanban: workflow run ${runId} rejected: ${err?.message}`)
354
+ await this.handleWorkflowResult(task.id, runId, role, {
355
+ stopReason: 'error',
356
+ error: err?.message || 'unknown error',
357
+ })
358
+ })
359
+
360
+ return {
361
+ mode: 'workflow',
362
+ started: true,
363
+ runId,
364
+ taskId: task.id,
365
+ role,
366
+ preset: preset || 'default',
367
+ sessionId: taken.sessionId,
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Отмена запущенного workflow.
373
+ */
374
+ async cancelTaskWorkflow(taskId, reason = 'cancelled-by-user') {
375
+ if (!taskId) return { cancelled: false, error: 'task-id-required' }
376
+
377
+ const entry = this.activeRuns.get(taskId)
378
+ if (!entry) {
379
+ // Возможно, статус в БД ещё числится running
380
+ const task = this.store.getTask(taskId)
381
+ if (task?.workflowStatus === 'running') {
382
+ this.store.updateTask(taskId, { workflowStatus: 'cancelled' })
383
+ return { cancelled: true, note: 'marked-cancelled-in-store' }
384
+ }
385
+ return { cancelled: false, error: 'no-active-workflow' }
386
+ }
387
+
388
+ try {
389
+ entry.run.cancel(reason)
390
+ } catch (err) {
391
+ this.logger?.warn?.(`dsh-kanban: error canceling workflow run ${entry.runId}: ${err.message}`)
392
+ }
393
+
394
+ this.store.updateTask(taskId, { workflowStatus: 'cancelled' })
395
+ this.store.addTransition({
396
+ taskId,
397
+ fromCol: entry.task?.col || entry.task?.column || 'progress',
398
+ toCol: entry.task?.col || entry.task?.column || 'progress',
399
+ source: 'workflow',
400
+ detail: `отмена workflow ${entry.runId}: ${reason}`,
401
+ })
402
+
403
+ this.eventHub?.broadcast?.('task:change', {
404
+ taskId,
405
+ action: 'workflow-cancelled',
406
+ runId: entry.runId,
407
+ reason,
408
+ })
409
+
410
+ return { cancelled: true, runId: entry.runId }
411
+ }
412
+
413
+ /**
414
+ * Обработка завершения выполнения workflow.
415
+ */
416
+ async handleWorkflowResult(taskId, runId, role, result) {
417
+ const entry = this.activeRuns.get(taskId)
418
+ const task = this.store.getTask(taskId) || entry?.task
419
+
420
+ this.activeRuns.delete(taskId)
421
+ this.runToTask.delete(runId)
422
+
423
+ if (!task) return
424
+
425
+ const stopReason = result?.stopReason || 'error'
426
+ const isCompleted = stopReason === 'completed'
427
+ const summary = typeof result?.value?.summary === 'string'
428
+ ? result.value.summary
429
+ : (result?.error || '')
430
+
431
+ const newStatus = isCompleted ? 'completed' : (stopReason === 'cancelled' ? 'cancelled' : 'failed')
432
+ this.store.updateTask(taskId, {
433
+ workflowStatus: newStatus,
434
+ })
435
+
436
+ if (isCompleted) {
437
+ const targetCol = resolveTargetColumn(task.col || task.column)
438
+ if (targetCol !== (task.col || task.column)) {
439
+ applyMove({
440
+ store: this.store,
441
+ config: this.getConfig(),
442
+ id: taskId,
443
+ column: targetCol,
444
+ source: 'workflow',
445
+ detail: `workflow ${runId} завершён успешно (роль: ${role})`,
446
+ })
447
+ }
448
+
449
+ // Отправляем комментарий в связанный issue Gitea / GitHub
450
+ if (this.git && task.repo && task.issueNumber) {
451
+ const commentBody = [
452
+ `🚀 **Workflow завершён успешно!** (роль: \`${role}\`)`,
453
+ summary ? `\n${summary}` : '',
454
+ `\n*Статус карточки переведён в колонку "${targetCol}".*`,
455
+ ].filter(Boolean).join('\n')
456
+
457
+ try {
458
+ await this.git.comment({
459
+ owner: task.owner,
460
+ repo: task.repo,
461
+ index: task.issueNumber,
462
+ body: commentBody,
463
+ provider: task.provider,
464
+ })
465
+ } catch (err) {
466
+ this.logger?.warn?.(`dsh-kanban: не удалось отправить комментарий в issue ${task.repo}#${task.issueNumber}: ${err.message}`)
467
+ }
468
+ }
469
+
470
+ // Добавляем комментарий в карточку
471
+ if (typeof this.store.addTaskComment === 'function' && summary) {
472
+ this.store.addTaskComment(taskId, {
473
+ author: 'workflow',
474
+ body: `🚀 **Workflow завершён (${role}):**\n\n${summary}`,
475
+ })
476
+ }
477
+ } else {
478
+ this.store.addTransition({
479
+ taskId,
480
+ fromCol: task.col || task.column,
481
+ toCol: task.col || task.column,
482
+ source: 'workflow',
483
+ detail: `workflow ${runId} завершён со статусом ${stopReason}: ${result?.error || ''}`,
484
+ })
485
+ }
486
+
487
+ if (entry?.run && typeof entry.run.dispose === 'function') {
488
+ try {
489
+ await entry.run.dispose()
490
+ } catch (err) {
491
+ this.logger?.warn?.(`dsh-kanban: ошибка при dispose workflow run ${runId}: ${err.message}`)
492
+ }
493
+ }
494
+
495
+ this.eventHub?.broadcast?.('task:change', {
496
+ taskId,
497
+ action: 'workflow-ended',
498
+ runId,
499
+ role,
500
+ status: newStatus,
501
+ stopReason,
502
+ })
503
+ }
504
+
505
+ /**
506
+ * Подключить слушатели событий ядра workflow/*.
507
+ *
508
+ * @param {object} ctx Cordis context
509
+ */
510
+ bindCoreEvents(ctx) {
511
+ if (!ctx || typeof ctx.on !== 'function') return () => {}
512
+
513
+ const unbind = [
514
+ ctx.on('workflow/agent-start', (runInfo, agentInfo) => {
515
+ const runId = String(runInfo?.id || '')
516
+ const taskId = this.runToTask.get(runId)
517
+ if (!taskId) return
518
+
519
+ if (agentInfo?.childId) {
520
+ this.store.updateTask(taskId, { sessionId: String(agentInfo.childId) })
521
+ this.eventHub?.broadcast?.('task:change', {
522
+ taskId,
523
+ action: 'workflow-agent-start',
524
+ runId,
525
+ sessionId: String(agentInfo.childId),
526
+ label: agentInfo.label,
527
+ })
528
+ }
529
+ }),
530
+
531
+ ctx.on('workflow/agent-end', (runInfo, endInfo) => {
532
+ const runId = String(runInfo?.id || '')
533
+ const taskId = this.runToTask.get(runId)
534
+ if (!taskId) return
535
+
536
+ this.eventHub?.broadcast?.('task:change', {
537
+ taskId,
538
+ action: 'workflow-agent-end',
539
+ runId,
540
+ outcome: endInfo?.outcome,
541
+ })
542
+ }),
543
+
544
+ ctx.on('workflow/end', (runInfo, resultInfo) => {
545
+ const runId = String(runInfo?.id || '')
546
+ const taskId = this.runToTask.get(runId)
547
+ if (!taskId) return
548
+
549
+ // handleWorkflowResult также вызывается через run.result.then,
550
+ // но событие workflow/end гарантирует синхронизацию наблюдателей
551
+ this.eventHub?.broadcast?.('task:change', {
552
+ taskId,
553
+ action: 'workflow-core-end',
554
+ runId,
555
+ stopReason: resultInfo?.stopReason,
556
+ })
557
+ }),
558
+ ]
559
+
560
+ return () => {
561
+ for (const fn of unbind) {
562
+ if (typeof fn === 'function') fn()
563
+ }
564
+ }
565
+ }
566
+ }
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.17",
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",