@goodandready/dsh-kanban 0.2.15 → 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 +1 -1
- package/README.ru.md +1 -1
- package/lib/client.js +54 -0
- package/lib/index.js +65 -2
- package/lib/store.js +60 -6
- package/lib/workflow-bridge.js +566 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/README.ru.md
CHANGED
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 === '
|
|
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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { DatabaseSync } from 'node:sqlite'
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
|
-
import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync } from 'node:fs'
|
|
16
|
+
import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync, chmodSync, existsSync } from 'node:fs'
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { keyBetween } from './order.js'
|
|
19
19
|
import { DEFAULT_BOARDS, normalizeKind } from './config.js'
|
|
@@ -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
|
|
@@ -159,13 +162,48 @@ function rowToTask(row) {
|
|
|
159
162
|
}
|
|
160
163
|
}
|
|
161
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Обеспечить безопасные права доступа к каталогу хранилища и файлам базы.
|
|
167
|
+
* Каталог и подкаталог бэкапов: 0700 (rwx------)
|
|
168
|
+
* Файлы базы, WAL, SHM и файлы бэкапов: 0600 (rw-------)
|
|
169
|
+
*
|
|
170
|
+
* @param {string} dir каталог хранилища
|
|
171
|
+
*/
|
|
172
|
+
export function secureStorePermissions(dir) {
|
|
173
|
+
try {
|
|
174
|
+
if (existsSync(dir)) {
|
|
175
|
+
try { chmodSync(dir, 0o700) } catch { /* best-effort chmod on dir */ }
|
|
176
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
const fullPath = join(dir, entry.name)
|
|
179
|
+
if (entry.isFile()) {
|
|
180
|
+
try { chmodSync(fullPath, 0o600) } catch { /* best-effort chmod on file */ }
|
|
181
|
+
} else if (entry.isDirectory() && entry.name === 'backups') {
|
|
182
|
+
try { chmodSync(fullPath, 0o700) } catch { /* best-effort chmod on backups dir */ }
|
|
183
|
+
try {
|
|
184
|
+
const backupFiles = readdirSync(fullPath, { withFileTypes: true })
|
|
185
|
+
for (const bFile of backupFiles) {
|
|
186
|
+
if (bFile.isFile()) {
|
|
187
|
+
try { chmodSync(join(fullPath, bFile.name), 0o600) } catch { /* best-effort chmod on backup file */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch { /* ignore readdir error on backups */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
/* best-effort permission hardening */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
/**
|
|
163
200
|
* Открыть хранилище задач.
|
|
164
201
|
* @param {{dir: string}} options каталог, в котором лежит файл базы
|
|
165
202
|
* @returns {object} интерфейс хранилища
|
|
166
203
|
*/
|
|
167
204
|
export function openStore({ dir }) {
|
|
168
|
-
mkdirSync(dir, { recursive: true })
|
|
205
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
206
|
+
secureStorePermissions(dir)
|
|
169
207
|
const db = new DatabaseSync(join(dir, 'kanban.db'))
|
|
170
208
|
db.exec('PRAGMA journal_mode = WAL')
|
|
171
209
|
db.exec('PRAGMA foreign_keys = ON')
|
|
@@ -206,6 +244,8 @@ export function openStore({ dir }) {
|
|
|
206
244
|
['isEpic', "INTEGER NOT NULL DEFAULT 0"],
|
|
207
245
|
['externalRef', "TEXT"],
|
|
208
246
|
['provider', "TEXT NOT NULL DEFAULT 'gitea'"],
|
|
247
|
+
['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
|
|
248
|
+
['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
209
249
|
]) {
|
|
210
250
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
211
251
|
}
|
|
@@ -226,6 +266,9 @@ export function openStore({ dir }) {
|
|
|
226
266
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
227
267
|
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
228
268
|
|
|
269
|
+
// Повторно обеспечиваем права на созданные/изменённые файлы базы, WAL и SHM
|
|
270
|
+
secureStorePermissions(dir)
|
|
271
|
+
|
|
229
272
|
const q = {
|
|
230
273
|
boards: db.prepare('SELECT * FROM boards ORDER BY position'),
|
|
231
274
|
insertTask: db.prepare(`INSERT INTO tasks
|
|
@@ -233,8 +276,9 @@ export function openStore({ dir }) {
|
|
|
233
276
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
234
277
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
235
278
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
|
|
236
|
-
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic
|
|
237
|
-
|
|
279
|
+
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
|
|
280
|
+
workflowRunId, workflowStatus)
|
|
281
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
238
282
|
boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
|
|
239
283
|
insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
|
|
240
284
|
lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
|
|
@@ -309,7 +353,8 @@ export function openStore({ dir }) {
|
|
|
309
353
|
|
|
310
354
|
backupDatabase() {
|
|
311
355
|
const backupsDir = join(dir, 'backups')
|
|
312
|
-
mkdirSync(backupsDir, { recursive: true })
|
|
356
|
+
mkdirSync(backupsDir, { recursive: true, mode: 0o700 })
|
|
357
|
+
try { chmodSync(backupsDir, 0o700) } catch { /* ignore */ }
|
|
313
358
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
314
359
|
const backupName = `kanban-backup-${stamp}.db`
|
|
315
360
|
const dest = join(backupsDir, backupName)
|
|
@@ -317,6 +362,7 @@ export function openStore({ dir }) {
|
|
|
317
362
|
db.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
|
318
363
|
} catch { /* wal checkpoint may not be strictly required */ }
|
|
319
364
|
copyFileSync(join(dir, 'kanban.db'), dest)
|
|
365
|
+
try { chmodSync(dest, 0o600) } catch { /* ignore */ }
|
|
320
366
|
|
|
321
367
|
// Ротация: сохраняем только последние 10 бэкапов
|
|
322
368
|
try {
|
|
@@ -432,6 +478,8 @@ export function openStore({ dir }) {
|
|
|
432
478
|
typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
|
|
433
479
|
input.parentId ?? '',
|
|
434
480
|
input.isEpic ? 1 : 0,
|
|
481
|
+
input.workflowRunId ?? '',
|
|
482
|
+
input.workflowStatus ?? '',
|
|
435
483
|
)
|
|
436
484
|
return rowToTask(q.taskById.get(id))
|
|
437
485
|
},
|
|
@@ -661,8 +709,14 @@ export function openStore({ dir }) {
|
|
|
661
709
|
}
|
|
662
710
|
},
|
|
663
711
|
|
|
712
|
+
securePermissions() {
|
|
713
|
+
secureStorePermissions(dir)
|
|
714
|
+
},
|
|
715
|
+
|
|
664
716
|
close() {
|
|
717
|
+
try { secureStorePermissions(dir) } catch { /* ignore */ }
|
|
665
718
|
db.close()
|
|
719
|
+
try { secureStorePermissions(dir) } catch { /* ignore */ }
|
|
666
720
|
},
|
|
667
721
|
}
|
|
668
722
|
}
|
|
@@ -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.
|
|
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",
|