@goodandready/dsh-kanban 0.2.33 → 0.2.35

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.
@@ -0,0 +1,530 @@
1
+ import {
2
+ createTask,
3
+ parseTaskPath,
4
+ taskLog,
5
+ taskBody,
6
+ deleteTask,
7
+ updateTask,
8
+ applyMove,
9
+ assignTask,
10
+ confirmPermission,
11
+ appendNote,
12
+ handleAcceptTask,
13
+ handleRejectTask,
14
+ handleTaskChecklist,
15
+ handleGetSubtasks,
16
+ handleCreateSubtask,
17
+ handleTriageTask,
18
+ handleCreateTaskPr,
19
+ handleTaskDiff,
20
+ handleTaskDiscard,
21
+ handleTaskCommits,
22
+ handleTaskMerge,
23
+ handleTaskWorktreeStatus,
24
+ handleCleanupWorktree,
25
+ handleTaskNudge,
26
+ handleProgressDump,
27
+ handleTaskReport,
28
+ handleGetTaskComments,
29
+ handleAddTaskComment,
30
+ handleToggleChecklistItem,
31
+ } from '../routes.js'
32
+ import { verifyAndRecordGate } from '../verification.js'
33
+ import { runTask, queueTask, unqueueTask, resolveModel, buildStartMessage, PERMISSION_RANKS } from '../launcher.js'
34
+ import { dispatchMove, stopWork, MOVE_DETAIL, STOP_DETAIL } from '../commands.js'
35
+ import { checkWorktreeDirty } from '../worktree.js'
36
+ import { refreshTask } from '../import.js'
37
+ import { parseProgressDump, formatProgressDump } from '../progress-dump.js'
38
+ import { rootOf } from '../config.js'
39
+ import { SessionId } from '@deepseek-ai/dsh-session'
40
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
41
+ import { randomUUID } from 'node:crypto'
42
+
43
+ export function createTaskRouteHandler(deps) {
44
+ const {
45
+ store,
46
+ getConfig,
47
+ eventHub,
48
+ ctx,
49
+ git,
50
+ gitea,
51
+ getGitRunner,
52
+ syncPower,
53
+ workflowBridge,
54
+ queueOutbound,
55
+ whoAmI,
56
+ defaultOwner,
57
+ watchdog,
58
+ urlOf,
59
+ writeJson,
60
+ reply,
61
+ readBody,
62
+ rejectUntrusted,
63
+ } = deps
64
+
65
+ return async (req, res) => {
66
+ const pathname = urlOf(req).pathname
67
+
68
+ // Path without ID handles local task creation.
69
+ if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
70
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
71
+ if (rejectUntrusted(req, res)) return
72
+ try {
73
+ const resObj = createTask({ store, input: await readBody(req) })
74
+ if (resObj?.task) {
75
+ eventHub.broadcast('task:change', { taskId: resObj.task.id, action: 'created' })
76
+ }
77
+ reply(res, resObj)
78
+ } catch (error) {
79
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
80
+ }
81
+ return
82
+ }
83
+
84
+ const parsed = parseTaskPath(pathname)
85
+ if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
86
+ const { id, action } = parsed
87
+
88
+ if (rejectUntrusted(req, res)) return
89
+
90
+ if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
91
+ if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
92
+
93
+ // Generate start prompt on server for template consistency.
94
+ if (req.method === 'GET' && action === 'message') {
95
+ const task = store.getTask(id)
96
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
97
+ writeJson(res, 200, { text: buildStartMessage(task, getConfig()) })
98
+ return
99
+ }
100
+ if (req.method === 'DELETE' && action === undefined) {
101
+ const resObj = deleteTask({ store, id })
102
+ if (resObj?.deleted) {
103
+ eventHub.broadcast('task:change', { taskId: id, action: 'deleted' })
104
+ }
105
+ reply(res, resObj)
106
+ return
107
+ }
108
+ // Card fields update: title, body, labels, priority. Priority is board-local.
109
+ if (req.method === 'POST' && action === 'update') {
110
+ try {
111
+ const input = await readBody(req)
112
+ const resObj = updateTask({ store, id, input })
113
+ if (resObj?.task) {
114
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
115
+ }
116
+ reply(res, resObj)
117
+ } catch {
118
+ writeJson(res, 400, { error: 'bad-request' })
119
+ }
120
+ return
121
+ }
122
+ if (req.method === 'POST' && action === 'refresh') {
123
+ reply(res, await refreshTask({ gitea: git, store, id }))
124
+ return
125
+ }
126
+ if (req.method === 'GET' && action === 'workflow-status') {
127
+ const task = store.getTask(id)
128
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
129
+ const active = workflowBridge.activeRuns.get(id)
130
+ reply(res, {
131
+ active: Boolean(active),
132
+ runId: active?.runId || task.workflowRunId || '',
133
+ role: active?.role || '',
134
+ status: task.workflowStatus || (active ? 'running' : 'idle'),
135
+ })
136
+ return
137
+ }
138
+ if (req.method === 'POST' && action === 'workflow-cancel') {
139
+ try {
140
+ const input = await readBody(req)
141
+ const reason = typeof input?.reason === 'string' ? input.reason : 'cancelled-by-user'
142
+ const result = await workflowBridge.cancelTaskWorkflow(id, reason)
143
+ syncPower()
144
+ reply(res, result)
145
+ } catch (error) {
146
+ writeJson(res, 500, { error: error?.message || 'cancel-failed' })
147
+ }
148
+ return
149
+ }
150
+ if (req.method === 'POST' && (action === 'start' || action === 'workflow-start')) {
151
+ try {
152
+ const input = await readBody(req)
153
+ const task = store.getTask(id)
154
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
155
+ const picked = resolveModel({
156
+ requested: input,
157
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
158
+ })
159
+ if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
160
+
161
+ if (action === 'workflow-start' || input.workflow === true || input.mode === 'workflow') {
162
+ const started = await workflowBridge.startTaskWorkflow({
163
+ task,
164
+ role: typeof input.role === 'string' ? input.role : (typeof input.agentPreset === 'string' ? input.agentPreset : undefined),
165
+ provider: picked.provider,
166
+ model: picked.model,
167
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
168
+ gitRunner: defaultGitRunner,
169
+ workspaceRegistry: ctx.get('workspaceRegistry'),
170
+ claimAs: await whoAmI(),
171
+ permission: typeof input.permission === 'string' ? input.permission : undefined,
172
+ permissions: ctx.get('permissionPresets'),
173
+ cwdOf: rootOf(getConfig()),
174
+ text: typeof input.text === 'string' ? input.text : undefined,
175
+ })
176
+ syncPower()
177
+ reply(res, started)
178
+ return
179
+ }
180
+
181
+ const started = await runTask({
182
+ agents: ctx.agents,
183
+ store,
184
+ task,
185
+ config: getConfig(),
186
+ provider: picked.provider,
187
+ model: picked.model,
188
+ // Mint session ID lazily to avoid dangling IDs on resume.
189
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
190
+ createMessage: createUserMessage,
191
+ logger: ctx.logger,
192
+ text: typeof input.text === 'string' ? input.text : undefined,
193
+ agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
194
+ permission: typeof input.permission === 'string' ? input.permission : undefined,
195
+ permissions: ctx.get('permissionPresets'),
196
+ workspaceRegistry: ctx.get('workspaceRegistry'),
197
+ claimAs: await whoAmI(),
198
+ })
199
+ syncPower()
200
+ reply(res, started)
201
+ } catch (error) {
202
+ ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
203
+ // Explicit permission errors are returned with status and key.
204
+ writeJson(res, error?.status ?? 500, { error: error?.key ?? error?.message ?? 'start-failed' })
205
+ }
206
+ return
207
+ }
208
+
209
+ try {
210
+ const input = await readBody(req)
211
+ if (req.method === 'POST' && action === 'move') {
212
+ const was = store.getTask(id)
213
+ if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
214
+
215
+ // Automated Verification Gate (#372): intercept transition to review
216
+ if (input.column === 'review' && was.column !== 'review') {
217
+ const gateResult = await verifyAndRecordGate({
218
+ task: was,
219
+ config: getConfig(),
220
+ store,
221
+ agents: ctx.agents,
222
+ createMessage: createUserMessage,
223
+ })
224
+ if (!gateResult.allowed) {
225
+ writeJson(res, 422, {
226
+ error: 'verification-failed',
227
+ command: gateResult.verification.command,
228
+ exitCode: gateResult.verification.exitCode,
229
+ log: gateResult.verification.logSnippet,
230
+ task: store.getTask(id),
231
+ })
232
+ return
233
+ }
234
+ }
235
+
236
+ // Manual move dispatches stop command to agent prior to updating card column.
237
+ const acted = was !== undefined && input.column !== was.column
238
+ ? dispatchMove({
239
+ agents: ctx.agents, task: was, column: input.column,
240
+ kind: store.boardKind(was.board),
241
+ createMessage: createUserMessage, logger: ctx.logger,
242
+ }).acted
243
+ : undefined
244
+ const out = applyMove({
245
+ store, config: getConfig(), id,
246
+ column: input.column, beforeId: input.beforeId, afterId: input.afterId,
247
+ detail: acted === undefined ? '' : (MOVE_DETAIL[acted] ?? ''),
248
+ })
249
+ if (out?.task !== undefined) {
250
+ eventHub.broadcast('task:change', { taskId: id, action: 'moved', toCol: out.task.column })
251
+ queueOutbound(id, out.task.column)
252
+ if (acted !== undefined) out.command = acted
253
+ if (input.column === 'cleanup' && was?.worktree) {
254
+ const dirty = await checkWorktreeDirty({ worktreePath: was.worktree })
255
+ if (dirty.dirty) {
256
+ out.worktreeWarning = 'worktree-dirty'
257
+ out.dirtyFiles = dirty.files
258
+ }
259
+ }
260
+ }
261
+ reply(res, out)
262
+ return
263
+ }
264
+ if (req.method === 'POST' && action === 'cleanup-worktree') {
265
+ const resObj = await handleCleanupWorktree({
266
+ store, task: store.getTask(id),
267
+ workspaceRegistry: ctx.get('workspaceRegistry'),
268
+ force: input?.force === true,
269
+ cleanupTaskWorktree,
270
+ gitRunner: getGitRunner(),
271
+ config: getConfig(),
272
+ repoDir: rootOf(getConfig()).path,
273
+ cwdOf: () => rootOf(getConfig()).path,
274
+ })
275
+ writeJson(res, resObj.status, resObj.body)
276
+ return
277
+ }
278
+ if (req.method === 'POST' && action === 'discard') {
279
+ const resObj = await handleTaskDiscard({
280
+ task: store.getTask(id),
281
+ file: input?.file,
282
+ discardTaskFile,
283
+ gitRunner: getGitRunner(),
284
+ })
285
+ if (resObj?.body?.success) {
286
+ eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
287
+ }
288
+ writeJson(res, resObj.status, resObj.body)
289
+ return
290
+ }
291
+ if (req.method === 'POST' && (action === 'nudge' || (input && input.action === 'nudge'))) {
292
+ const resObj = await handleTaskNudge({
293
+ store,
294
+ id,
295
+ prompt: input?.prompt || input?.text,
296
+ agents: ctx.agents,
297
+ createMessage: createUserMessage,
298
+ eventHub,
299
+ })
300
+ writeJson(res, resObj.status, resObj.body)
301
+ return
302
+ }
303
+ if (req.method === 'GET' && action === 'diff') {
304
+ const resObj = await handleTaskDiff({
305
+ task: store.getTask(id),
306
+ getTaskDiff: (opts) => getTaskDiff({ ...opts, gitRunner: getGitRunner() }),
307
+ getTaskDiffInspector: (opts) => getTaskDiffInspector({ ...opts, gitRunner: getGitRunner() }),
308
+ })
309
+ writeJson(res, resObj.status, resObj.body)
310
+ return
311
+ }
312
+ if (req.method === 'GET' && action === 'commits') {
313
+ const resObj = await handleTaskCommits({
314
+ task: store.getTask(id),
315
+ getTaskCommits: (opts) => getTaskCommits({ ...opts, gitRunner: getGitRunner() }),
316
+ })
317
+ writeJson(res, resObj.status, resObj.body)
318
+ return
319
+ }
320
+ if (req.method === 'POST' && action === 'merge') {
321
+ const resObj = await handleTaskMerge({
322
+ store, task: store.getTask(id),
323
+ targetBranch: input?.targetBranch,
324
+ workspaceRegistry: ctx.get('workspaceRegistry'),
325
+ mergeTaskWorktree,
326
+ gitRunner: getGitRunner(),
327
+ })
328
+ writeJson(res, resObj.status, resObj.body)
329
+ return
330
+ }
331
+ if (req.method === 'GET' && action === 'worktree-status') {
332
+ const resObj = await handleTaskWorktreeStatus({ task: store.getTask(id), checkWorktreeDirty })
333
+ writeJson(res, resObj.status, resObj.body)
334
+ return
335
+ }
336
+ if (req.method === 'GET' && action === 'subtasks') {
337
+ const resObj = handleGetSubtasks({ store, task: store.getTask(id) })
338
+ writeJson(res, resObj.status, resObj.body)
339
+ return
340
+ }
341
+ if (req.method === 'POST' && action === 'triage') {
342
+ const resObj = await handleTriageTask({ store, gitea: git, task: store.getTask(id), input })
343
+ writeJson(res, resObj.status, resObj.body)
344
+ return
345
+ }
346
+ if (req.method === 'POST' && (action === 'subtask' || action === 'subtasks')) {
347
+ const resObj = await handleCreateSubtask({ store, gitea: git, task: store.getTask(id), input })
348
+ writeJson(res, resObj.status, resObj.body)
349
+ return
350
+ }
351
+ if (req.method === 'POST' && action === 'checklist-toggle') {
352
+ const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
353
+ if (resObj?.body?.task) {
354
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
355
+ }
356
+ writeJson(res, resObj.status, resObj.body)
357
+ return
358
+ }
359
+ if (req.method === 'POST' && action === 'pr') {
360
+ const resObj = await handleCreateTaskPr({ store, gitea: git, task: store.getTask(id), input })
361
+ writeJson(res, resObj.status, resObj.body)
362
+ return
363
+ }
364
+ if (req.method === 'POST' && action === 'checklist') {
365
+ const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
366
+ if (resObj?.body?.task) {
367
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
368
+ }
369
+ writeJson(res, resObj.status, resObj.body)
370
+ return
371
+ }
372
+ if (req.method === 'POST' && action === 'report') {
373
+ const resObj = handleTaskReport({ store, task: store.getTask(id), input })
374
+ if (resObj?.body?.task || resObj?.body?.report) {
375
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
376
+ }
377
+ writeJson(res, resObj.status, resObj.body)
378
+ return
379
+ }
380
+ if (req.method === 'GET' && action === 'comments') {
381
+ const resObj = handleGetTaskComments({ task: store.getTask(id) })
382
+ writeJson(res, resObj.status, resObj.body)
383
+ return
384
+ }
385
+ if (req.method === 'POST' && action === 'comments') {
386
+ const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
387
+ if (resObj?.body?.task || resObj?.status === 200) {
388
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
389
+ }
390
+ writeJson(res, resObj.status, resObj.body)
391
+ return
392
+ }
393
+ if (req.method === 'POST' && action === 'accept') {
394
+ const resObj = handleAcceptTask({ store, task: store.getTask(id) })
395
+ writeJson(res, resObj.status, resObj.body)
396
+ return
397
+ }
398
+ if (req.method === 'POST' && action === 'reject') {
399
+ const resObj = handleRejectTask({ store, task: store.getTask(id), input })
400
+ writeJson(res, resObj.status, resObj.body)
401
+ return
402
+ }
403
+ if (req.method === 'POST' && action === 'resume') {
404
+ watchdog?.resumeTask?.({ store, taskId: id, eventHub })
405
+ const task = store.getTask(id)
406
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
407
+ const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
408
+ const picked = resolveModel({
409
+ requested: { model: input?.model || task.model, provider: input?.provider || task.provider },
410
+ fallback,
411
+ })
412
+ if (picked.error) { reply(res, picked); return }
413
+ const out = await runTask({
414
+ agents: ctx.agents,
415
+ store,
416
+ task,
417
+ config: getConfig(),
418
+ provider: picked.provider,
419
+ model: picked.model,
420
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
421
+ createMessage: createUserMessage,
422
+ cwdOf: undefined,
423
+ logger: ctx.logger,
424
+ text: typeof input?.text === 'string' ? input.text : undefined,
425
+ agentPreset: input?.agentPreset || task.agentPreset,
426
+ permission: input?.permission || task.permission,
427
+ permissions: ctx.get('permissionPresets'),
428
+ claimAs: await whoAmI(),
429
+ workspaceRegistry: ctx.get('workspaceRegistry'),
430
+ resume: true,
431
+ })
432
+ syncPower()
433
+ reply(res, out)
434
+ return
435
+ }
436
+ if (req.method === 'POST' && action === 'confirm-permission') {
437
+ reply(res, confirmPermission({ store, id }))
438
+ return
439
+ }
440
+ if (req.method === 'POST' && action === 'progress-dump') {
441
+ const resObj = handleProgressDump({ store, task: store.getTask(id), input, formatProgressDump })
442
+ writeJson(res, resObj.status, resObj.body)
443
+ return
444
+ }
445
+ if (req.method === 'POST' && action === 'stop') {
446
+ const task = store.getTask(id)
447
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
448
+ const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
449
+ if (out.acted === 'stopped') {
450
+ const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
451
+ if (latestRun && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
452
+ store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
453
+ }
454
+ syncPower()
455
+ // Stop event recorded in transition history.
456
+ store.addTransition({
457
+ taskId: task.id, fromCol: task.column, toCol: task.column,
458
+ source: 'manual', detail: STOP_DETAIL.stopped,
459
+ })
460
+ }
461
+ reply(res, out)
462
+ return
463
+ }
464
+ if (req.method === 'POST' && action === 'assign') {
465
+ const was = store.getTask(id)
466
+ if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
467
+ // Empty login unassigns; 'me' resolves authenticated token identity.
468
+ let login = typeof input.login === 'string' ? input.login.trim() : ''
469
+ if (login === 'me') {
470
+ login = await whoAmI()
471
+ if (login === '') { writeJson(res, 502, { error: 'gitea-unreachable' }); return }
472
+ }
473
+ const out = assignTask({ store, id, login })
474
+ if (out.task !== undefined && config.pushToGitea !== false) {
475
+ // Queue assignment update to outbox for offline resilience.
476
+ outbox.push(id, planAssign(out.task, login))
477
+ }
478
+ reply(res, out)
479
+ return
480
+ }
481
+ if (req.method === 'POST' && action === 'archive') {
482
+ const resObj = setArchived({ store, id, archived: true })
483
+ if (resObj?.task) {
484
+ eventHub.broadcast('task:change', { taskId: id, action: 'archived' })
485
+ }
486
+ reply(res, resObj)
487
+ return
488
+ }
489
+ if (req.method === 'POST' && action === 'restore') {
490
+ const resObj = setArchived({ store, id, archived: false })
491
+ if (resObj?.task) {
492
+ eventHub.broadcast('task:change', { taskId: id, action: 'restored' })
493
+ }
494
+ reply(res, resObj)
495
+ return
496
+ }
497
+ if (req.method === 'POST' && action === 'queue') {
498
+ reply(res, await queueTask({
499
+ agents: ctx.agents, store, task: store.getTask(id),
500
+ sessionId: input.sessionId, config: getConfig(),
501
+ createMessage: createUserMessage, text: input.text,
502
+ }))
503
+ return
504
+ }
505
+ if (req.method === 'POST' && action === 'unqueue') {
506
+ reply(res, unqueueTask({ store, task: store.getTask(id) }))
507
+ return
508
+ }
509
+ if (req.method === 'POST' && action === 'note') {
510
+ const out = appendNote({ store, id, input })
511
+ if (out?.task || out?.text) {
512
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
513
+ }
514
+ reply(res, out)
515
+ return
516
+ }
517
+ if (req.method === 'PATCH' && (action === undefined || action === '')) {
518
+ const resObj = updateTask({ store, id, input })
519
+ if (resObj?.task) {
520
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
521
+ }
522
+ reply(res, resObj)
523
+ return
524
+ }
525
+ writeJson(res, 405, { error: 'method-not-allowed' })
526
+ } catch (error) {
527
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
528
+ }
529
+ }
530
+ }