@goodandready/dsh-kanban 0.2.34 → 0.2.36

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