@goodandready/dsh-kanban 0.2.34 → 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.
package/lib/index.js CHANGED
@@ -1,101 +1,26 @@
1
- import { verifyAndRecordGate } from './verification.js'
2
1
  import { registerPluginUpdater } from './updater.js'
3
- // dsh-kanban — task board for DeepSeek Harness.
4
- //
5
- // Plugin consists of two halves: this module is the backend server, and `lib/client.js` is the
6
- // browser bundle. The package name `@goodandready/dsh-kanban` must match in three places:
7
- // `package.json`, `cordis.patch.yml`, and `load({ id })` in client.js.
8
- //
9
- // Pure domain logic is kept in standalone modules (`config.js`, `order.js`, `routes.js`)
10
- // and tested without harness runtime. This module handles cordis integration:
11
- // configuration, database storage, and HTTP endpoints.
12
-
13
2
  import {
14
3
  MAX_BODY_BYTES,
15
4
  isTrustedRequest,
16
5
  rejectUntrustedRequest,
17
- getRejectionStatus,
18
- parseTaskPath,
19
- buildBoard,
20
- applyMove,
21
- createTask,
22
- updateTask,
23
- confirmPermission,
24
- appendNote,
25
- setArchived,
26
- listArchive,
27
- assignTask,
28
- deleteTask,
29
- boardMetrics,
30
- taskBody,
31
- taskLog,
32
- taskBySession,
33
- exportBoard,
34
- importBoard,
35
- exportTasksCsv,
36
- handleCleanupWorktree,
37
- handleTaskDiff,
38
- handleTaskDiscard,
39
- handleTaskCommits,
40
- handleTaskMerge,
41
- handleTaskWorktreeStatus,
42
- handleTaskChecklist,
43
- handleTaskReport,
44
- handleGetTaskComments,
45
- handleAddTaskComment,
46
- handleAcceptTask,
47
- handleRejectTask,
48
- handleProgressDump,
49
- handleTriageTask,
50
- handleGetSubtasks,
51
- handleCreateSubtask,
52
- handleToggleChecklistItem,
53
- handleCreateTaskPr,
54
- handleTaskNudge,
55
6
  } from './routes.js'
56
7
  import { isCostMeterAvailable } from './cost-telemetry.js'
57
- import { createRemoteGitRunner, testRemoteConnection, isRemoteMode } from './remote-runner.js'
58
- import { shouldRelay, relayRequest, testRemoteServer } from './remote-relay.js'
59
- import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
60
- import {
61
- runTask, resolveModel, buildStartMessage,
62
- runBatch, queueTask, unqueueTask, liveSessions, dispatchNextQueuedTask,
63
- PERMISSION_RANKS,
64
- } from './launcher.js'
65
- import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree, defaultGitRunner } from './worktree.js'
66
- import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
8
+ import { createRemoteGitRunner } from './remote-runner.js'
67
9
  import { createGiteaClient } from './gitea.js'
68
10
  import { createGitHubClient } from './github.js'
69
11
  import { createGitProvider } from './git-provider.js'
70
12
  import { createKanbanTaskService } from './task-service.js'
71
- import { WorkflowBridge } from './workflow-bridge.js'
72
- import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
73
- import { isWatched } from './intake.js'
74
- import { handleSessionEvent } from './lifecycle.js'
75
- import {
76
- boardMoveDefinition,
77
- boardPlanDefinition,
78
- boardChecklistDefinition,
79
- boardReportDefinition,
80
- boardCommentsDefinition,
81
- boardCommentAddDefinition,
82
- boardDecomposeDefinition,
83
- boardChecklistItemDefinition,
84
- } from './board-tool.js'
85
- import { verifySignature, parseEvent, detectWebhookProvider } from './webhook.js'
86
- import { planOutbound, planAssign, createOutbox } from './outbound.js'
87
- import { computeNextRun } from './cron.js'
88
- import { PowerInhibitor } from './power.js'
89
- import { formatProgressDump, parseProgressDump } from './progress-dump.js'
90
- import { randomUUID } from 'node:crypto'
91
- import { SessionId } from '@deepseek-ai/dsh-session'
92
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
13
+ import { eventHub } from './events.js'
14
+ import { openStore } from './store.js'
15
+ import { defaultGitRunner } from './worktree.js'
93
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
94
17
  import { defineTool } from '@deepseek-ai/dsh-tools'
95
18
  import z from '@deepseek-ai/schemastery'
96
19
  import { join } from 'node:path'
97
20
  import { homedir } from 'node:os'
98
- import { openStore } from './store.js'
21
+ import { randomUUID } from 'node:crypto'
22
+ import { SessionId } from '@deepseek-ai/dsh-session'
23
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
99
24
  import {
100
25
  COLUMN_ORDER,
101
26
  wipLimitField,
@@ -105,8 +30,11 @@ import {
105
30
  buildPromptSectionText,
106
31
  rootOf,
107
32
  } from './config.js'
108
- import { eventHub } from './events.js'
109
- import { TASK_TEMPLATES, getTemplateById } from './templates.js'
33
+
34
+ import { registerKanbanServices } from './server/services.js'
35
+ import { registerKanbanTimers } from './server/timers.js'
36
+ import { createRouteHandlers } from './server/route-handlers.js'
37
+ import { createTaskRouteHandler } from './server/task-handler.js'
110
38
 
111
39
  export { COLUMN_ORDER, buildPromptSectionText, wipLimitField, withDefaults } from './config.js'
112
40
 
@@ -120,7 +48,6 @@ export const name = '@goodandready/dsh-kanban'
120
48
  export const inject = ['settings', 'webServer', 'agents', 'agentDefaultModel', 'credentials', 'llm', 'tools', 'connection']
121
49
 
122
50
  /** Settings namespace string matching client bundle definition. */
123
- // Constant string defined in this file; identical across core versions.
124
51
  export const SETTINGS_NAMESPACE = 'dsh-kanban'
125
52
 
126
53
  /**
@@ -130,7 +57,6 @@ export const SETTINGS_NAMESPACE = 'dsh-kanban'
130
57
  */
131
58
  export const Config = z.object(Object.fromEntries(
132
59
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
133
- // Node type derived from default value type.
134
60
  let node = typeof fallback === 'number' ? z.number()
135
61
  : typeof fallback === 'boolean' ? z.boolean()
136
62
  : z.string()
@@ -154,7 +80,6 @@ function writeJson(res, code, body) {
154
80
  } catch { /* socket closed */ }
155
81
  }
156
82
 
157
- /** Handler response helper: either an error object or success payload. */
158
83
  function reply(res, out) {
159
84
  if (out && typeof out === 'object' && typeof out.error === 'string') {
160
85
  writeJson(res, out.status ?? 400, { error: out.error })
@@ -185,11 +110,6 @@ function readBody(req) {
185
110
  })
186
111
  }
187
112
 
188
- /**
189
- * Read raw request body bytes.
190
- * Webhook signatures are computed over delivery byte buffers; parsing and re-serializing
191
- * could alter byte sequences and fail signature verification.
192
- */
193
113
  function readRaw(req) {
194
114
  return new Promise((resolve, reject) => {
195
115
  const chunks = []
@@ -217,8 +137,6 @@ export function apply(ctx, config) {
217
137
  const getConfig = () => effective
218
138
  const getGitRunner = () => createRemoteGitRunner(getConfig(), defaultGitRunner)
219
139
 
220
- // Settings control live subscriptions: sync intervals and agent tools.
221
- // Dynamic watcher re-evaluates configurations on change without requiring restart.
222
140
  const rearmers = new Set()
223
141
  const rearm = () => {
224
142
  for (const again of rearmers) {
@@ -253,7 +171,6 @@ export function apply(ctx, config) {
253
171
  }
254
172
  })
255
173
 
256
- // Listen for config and volatile updates from host
257
174
  if (typeof ctx?.on === 'function') {
258
175
  const applyPatch = (updated) => {
259
176
  if (updated && typeof updated === 'object') {
@@ -295,12 +212,10 @@ export function apply(ctx, config) {
295
212
  }
296
213
  }
297
214
 
298
- // Dedicated Gitea client resolves token via core credentials service.
299
- // Secret token values are never exposed externally; only the credential reference name is stored.
300
- const resolveSecret = async (name) => {
301
- if (typeof name !== 'string' || name.trim() === '') return ''
215
+ const resolveSecret = async (secretName) => {
216
+ if (typeof secretName !== 'string' || secretName.trim() === '') return ''
302
217
  try {
303
- const resolved = await ctx.credentials.resolve(credentialRef(name.trim()))
218
+ const resolved = await ctx.credentials.resolve(credentialRef(secretName.trim()))
304
219
  return resolved?.value ?? ''
305
220
  } catch {
306
221
  return ''
@@ -323,7 +238,6 @@ export function apply(ctx, config) {
323
238
  getConfig,
324
239
  })
325
240
 
326
- // Database store opens once and is closed on effect disposal.
327
241
  let store
328
242
  const taskService = createKanbanTaskService({ getStore: () => store })
329
243
  if (typeof ctx.provide === 'function') ctx.provide('dshKanban', taskService)
@@ -332,317 +246,17 @@ export function apply(ctx, config) {
332
246
  return () => { try { store.close() } catch { /* already closed */ } }
333
247
  }, 'dsh-kanban: task store')
334
248
 
335
- // Cross-platform system sleep inhibitor while tasks are actively running
336
- const powerInhibitor = new PowerInhibitor({
337
- platform: process.platform,
338
- pid: process.pid,
339
- })
340
-
341
- const workflowBridge = new WorkflowBridge({
342
- get store() { return store },
343
- get workflowEngine() { return ctx.get('workflowEngine') },
344
- get agentPresets() { return ctx.get('agentPresets') },
345
- get agents() { return ctx.agents },
346
- git,
347
- getConfig,
348
- logger: ctx.logger,
349
- eventHub,
350
- })
351
- const unbindCoreWorkflow = workflowBridge.bindCoreEvents(ctx)
352
- if (typeof ctx.on === 'function') {
353
- ctx.on('dispose', () => {
354
- unbindCoreWorkflow?.()
355
- })
356
- }
357
-
358
- const syncPower = () => {
359
- try {
360
- const cfg = getConfig()
361
- powerInhibitor.setEnabled(cfg.preventIdleSleep === true)
362
- if (store) {
363
- const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
364
- powerInhibitor.setReasons(activeCount)
365
- }
366
- } catch { /* ignore */ }
367
- }
368
- rearmers.add(syncPower)
369
- ctx.effect(() => {
370
- syncPower()
371
- return () => { powerInhibitor.destroy() }
372
- }, 'dsh-kanban: power inhibitor')
373
-
374
- // Inject board instructions into agent system prompt
375
- let disposePromptSection
376
- const syncPromptSection = () => {
377
- if (disposePromptSection) {
378
- try { disposePromptSection() } catch { /* cleanup best effort */ }
379
- disposePromptSection = undefined
380
- }
381
- const cfg = getConfig()
382
- const sp = ctx.get('systemPrompt')
383
- const promptText = buildPromptSectionText(cfg)
384
- if (promptText && sp && typeof sp.section === 'function') {
385
- try {
386
- disposePromptSection = sp.section({
387
- name: 'plugin:dsh-kanban',
388
- order: 200,
389
- text: promptText,
390
- })
391
- } catch (err) {
392
- ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
393
- }
394
- }
395
- }
396
- rearmers.add(syncPromptSection)
397
- ctx.effect(() => {
398
- syncPromptSection()
399
- return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
400
- }, 'dsh-kanban: system prompt section')
401
-
402
- // Track waiting flags, session outcomes, and progress dump from session events.
403
- ctx.effect(() => ctx.on('session/event', (session, event) => {
404
- try {
405
- // Auto-sync DSH chat sessions into kanban cards (#225)
406
- const sessionIdStr = String(session?.id ?? '')
407
- if (
408
- getConfig().autoSyncSessions === true &&
409
- session &&
410
- !session.isSubagent &&
411
- session.origin !== 'subagent' &&
412
- session.parentId === undefined &&
413
- session.meta?.origin !== 'subagent' &&
414
- !sessionIdStr.startsWith('kanban-') &&
415
- !sessionIdStr.startsWith('kanban_')
416
- ) {
417
- let existingTask = store?.findTaskBySession?.(sessionIdStr)
418
- if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
419
- const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
420
- const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Dialog ${sessionIdStr.slice(0, 8)}`)
421
- const createdTask = store.createTask({
422
- title: sessionTitle,
423
- body: userText || `Automatically created from chat session ${sessionIdStr}`,
424
- column: 'in-progress',
425
- board: 'main',
426
- sessionId: sessionIdStr,
427
- labels: ['chat-sync'],
428
- })
429
- if (createdTask) {
430
- existingTask = createdTask
431
- eventHub.broadcast('task:change', { taskId: createdTask.id, action: 'created' })
432
- }
433
- } else if (existingTask) {
434
- if (event?.type === 'turn/end' || event?.type === 'turn/completed') {
435
- if (existingTask.column === 'in-progress') {
436
- store.moveTask(existingTask.id, { column: 'review' })
437
- eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'review' })
438
- }
439
- } else if (event?.type === 'turn/error' || event?.type === 'session/error') {
440
- if (existingTask.column === 'in-progress') {
441
- store.moveTask(existingTask.id, { column: 'todo' })
442
- eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'todo' })
443
- }
444
- }
445
- }
446
- }
447
-
448
- const text = event?.content?.[0]?.text ?? event?.text ?? ''
449
- const res = handleSessionEvent({
450
- store,
451
- sessionId: String(session?.id ?? ''),
452
- session,
453
- type: event?.type,
454
- text,
455
- error: event?.error,
456
- })
457
- syncPower()
458
- // Dispatch next queued task when a session completes or fails (#214)
459
- if (event?.type === 'turn/end' || event?.type === 'turn/completed' || event?.type === 'session/error' || event?.type === 'turn/error') {
460
- dispatchNextQueuedTask({
461
- agents: ctx.agents,
462
- store,
463
- config: getConfig(),
464
- mintSessionId: () => SessionId(`kanban-queued-${randomUUID()}`),
465
- createMessage: createUserMessage,
466
- logger: ctx.logger,
467
- permissions: ctx.get('permissionPresets'),
468
- workspaceRegistry: ctx.get('workspaceRegistry'),
469
- claimAs: 'queue-dispatcher',
470
- }).catch(() => {})
471
- }
472
- } catch (error) {
473
- ctx.logger?.warn?.(`dsh-kanban: session event handler failed: ${error?.message}`)
474
- }
475
- }), 'dsh-kanban: session events')
476
-
477
- // Background cron scheduler for scheduled tasks
478
- ctx.effect(() => {
479
- let cronTimer
480
- const armCron = () => {
481
- clearInterval(cronTimer)
482
- cronTimer = undefined
483
- if (getConfig().cronEnabled !== true) return
484
- cronTimer = setInterval(async () => {
485
- try {
486
- if (!store) return
487
- const now = Date.now()
488
- const dueTasks = store.listDueTasks(now)
489
- for (const task of dueTasks) {
490
- if (task.column === 'in-progress') {
491
- const next = computeNextRun(task.cron, now)
492
- store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
493
- continue
494
- }
495
- if (getConfig().permissionGateEnabled) {
496
- const perm = task.permission || 'read-only'
497
- const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
498
- if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
499
- const next = computeNextRun(task.cron, now)
500
- store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
501
- store.addTransition({
502
- taskId: task.id,
503
- fromCol: task.column,
504
- toCol: task.column,
505
- source: 'tool',
506
- detail: 'cron: run rejected (elevated permission required)',
507
- })
508
- continue
509
- }
510
- }
511
- const picked = resolveModel({
512
- requested: { model: task.model, provider: task.provider },
513
- fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
514
- })
515
- if (picked.model) {
516
- await runTask({
517
- agents: ctx.agents,
518
- store,
519
- task,
520
- config: getConfig(),
521
- provider: picked.provider,
522
- model: picked.model,
523
- mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
524
- createMessage: createUserMessage,
525
- logger: ctx.logger,
526
- workspaceRegistry: ctx.get('workspaceRegistry'),
527
- claimAs: 'cron',
528
- })
529
- syncPower()
530
- }
531
- const next = computeNextRun(task.cron, now)
532
- store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
533
- }
534
- } catch (err) {
535
- ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
536
- }
537
- }, 30_000)
538
- if (typeof cronTimer.unref === 'function') cronTimer.unref()
539
- }
540
- armCron()
541
- rearmers.add(armCron)
542
- return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
543
- }, 'dsh-kanban: cron scheduler')
544
-
545
- // Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
546
- // Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
547
- const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
548
-
549
- const queueOutbound = (taskId, column) => {
550
- if (getConfig().pushToGitea !== true) return
551
- const task = store.getTask(taskId)
552
- if (task === undefined) return
553
- const ops = planOutbound(task, column, getConfig())
554
- if (ops.length > 0) outbox.push(taskId, ops)
555
- }
556
-
557
- /**
558
- * Default repository owner for short repository names.
559
- * Inferred from existing board tasks when not explicitly configured.
560
- */
561
- let discoveredOwner
562
- const defaultOwner = async () => {
563
- const named = String(getConfig().giteaOwner ?? '').trim()
564
- if (named !== '') return named
565
- for (const task of store.listWatchable()) {
566
- if (typeof task.owner === 'string' && task.owner !== '') return task.owner
567
- }
568
- if (discoveredOwner !== undefined) return discoveredOwner
569
- // Fall back to Gitea organization if exactly one is accessible to the token.
570
- const orgs = await gitea.listOrgs()
571
- if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
572
- return undefined
573
- }
574
-
575
- // Sync telemetry state visible in board header.
576
- const syncState = createSyncState()
577
-
578
- let syncing = false
579
- const runSync = async () => {
580
- if (syncing) return { skipped: true }
581
- syncing = true
582
- syncState.started()
249
+ let knownLogin
250
+ const whoAmI = async () => {
251
+ if (knownLogin !== undefined) return knownLogin
583
252
  try {
584
- // Order of operations: intake new issues first, reconcile active tasks, archive overdue.
585
- // Inspect final outcome rather than initial state.
586
- const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
587
- const providersToSync = []
588
- if (mode === 'github') {
589
- providersToSync.push(github)
590
- } else if (mode === 'gitea') {
591
- providersToSync.push(gitea)
592
- } else {
593
- if (await gitea.isConfigured()) providersToSync.push(gitea)
594
- if (await github.isConfigured()) providersToSync.push(github)
595
- if (providersToSync.length === 0) providersToSync.push(gitea)
596
- }
597
-
598
- let taken = { added: 0, skipped: 0, failed: 0 }
599
- for (const p of providersToSync) {
600
- const owner = p === github
601
- ? (String(getConfig()?.githubOwner ?? '').trim() || await p.me().catch(() => ''))
602
- : await defaultOwner()
603
- const res = await intakeAll({
604
- gitea: p, store, config: getConfig(), owner, logger: ctx.logger,
605
- })
606
- if (res && typeof res.added === 'number') {
607
- taken.added = (taken.added || 0) + res.added
608
- taken.skipped = (taken.skipped || 0) + (res.skipped || 0)
609
- taken.failed = (taken.failed || 0) + (res.failed || 0)
610
- }
611
- if (res?.problem) taken.problem = res.problem
612
- }
613
- const out = await syncAll({ gitea: git, store, logger: ctx.logger })
614
- const tidied = archiveOverdue({ store, config: getConfig() })
615
- // Outbox flushes run in the same cycle to minimize redundant Gitea API calls.
616
- const pushed = await outbox.flush()
617
- // Intake errors are reported in sync telemetry to keep failures visible.
618
- syncState.finished(out.problem === undefined && taken.problem !== undefined
619
- ? { ...out, problem: taken.problem }
620
- : out)
621
- return { ...out, pushed, taken, tidied }
622
- } catch (error) {
623
- // Sync failure to start represents missing client or unconfigured credentials.
624
- syncState.failedToStart('sync', error?.message)
625
- throw error
626
- } finally {
627
- syncing = false
253
+ knownLogin = await gitea.me()
254
+ } catch {
255
+ knownLogin = ''
628
256
  }
257
+ return knownLogin
629
258
  }
630
259
 
631
- ctx.effect(() => {
632
- let timer
633
- const arm = () => {
634
- const seconds = Number(getConfig().syncIntervalSec) || 0
635
- clearInterval(timer)
636
- timer = undefined
637
- if (seconds <= 0) return
638
- timer = setInterval(() => { runSync().catch(() => {}) }, Math.max(seconds, 15) * 1000)
639
- if (typeof timer.unref === 'function') timer.unref()
640
- }
641
- arm()
642
- rearmers.add(arm)
643
- return () => { rearmers.delete(arm); clearInterval(timer) }
644
- }, 'dsh-kanban: sync timer')
645
-
646
260
  const resolveConnection = () => {
647
261
  try {
648
262
  if (ctx && typeof ctx.get === 'function') {
@@ -667,363 +281,156 @@ export function apply(ctx, config) {
667
281
  const isTrusted = (req) => isTrustedRequest(req, routeOptions())
668
282
  const rejectUntrusted = (req, res) => rejectUntrustedRequest(req, res, routeOptions())
669
283
 
284
+ const services = registerKanbanServices(ctx, {
285
+ store,
286
+ getConfig,
287
+ eventHub,
288
+ git,
289
+ rearmers,
290
+ })
291
+
292
+ const timers = registerKanbanTimers(ctx, {
293
+ store,
294
+ getConfig,
295
+ eventHub,
296
+ syncPower: services.syncPower,
297
+ git,
298
+ gitea,
299
+ github,
300
+ rearmers,
301
+ watchdog: services.watchdog,
302
+ })
303
+
304
+ const handlers = createRouteHandlers({
305
+ ctx,
306
+ store,
307
+ getConfig,
308
+ git,
309
+ gitea,
310
+ github,
311
+ getGitRunner,
312
+ eventHub,
313
+ syncState: timers.syncState,
314
+ runSync: timers.runSync,
315
+ liveOf: services.liveOf,
316
+ defaultOwner: timers.defaultOwner,
317
+ whoAmI,
318
+ isCostMeterAvailable,
319
+ resolveSecret,
320
+ isTrusted,
321
+ urlOf,
322
+ writeJson,
323
+ reply,
324
+ readBody,
325
+ readRaw,
326
+ rejectUntrusted,
327
+ })
328
+
329
+ const taskHandler = createTaskRouteHandler({
330
+ store,
331
+ getConfig,
332
+ eventHub,
333
+ ctx,
334
+ git,
335
+ gitea,
336
+ getGitRunner,
337
+ syncPower: services.syncPower,
338
+ workflowBridge: services.workflowBridge,
339
+ queueOutbound: timers.queueOutbound,
340
+ watchdog: services.watchdog,
341
+ whoAmI,
342
+ defaultOwner: timers.defaultOwner,
343
+ urlOf,
344
+ writeJson,
345
+ reply,
346
+ readBody,
347
+ rejectUntrusted,
348
+ })
349
+
670
350
  ctx.effect(() => ctx.webServer.register({
671
351
  kind: 'exact',
672
352
  path: '/dsh-kanban/project-task',
673
- handler: async (req, res) => {
674
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
675
- if (rejectUntrusted(req, res)) return
676
- try {
677
- const input = await readBody(req)
678
- reply(res, await createProjectTask({
679
- gitea: git, store,
680
- owner: input.owner || await defaultOwner(),
681
- repo: input.repo, newRepo: input.newRepo,
682
- title: input.title, body: input.body,
683
- board: input.board ?? 'main',
684
- }))
685
- } catch (error) {
686
- writeJson(res, error?.status ?? 500, { error: 'bad-request' })
687
- }
688
- },
353
+ handler: handlers.handleProjectTask,
689
354
  }), 'dsh-kanban: project task')
690
355
 
691
356
  ctx.effect(() => ctx.webServer.register({
692
357
  kind: 'exact',
693
358
  path: '/dsh-kanban/presets',
694
- // Both services are optional in minimal environments. Empty lists render gracefully.
695
- handler: async (req, res) => {
696
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
697
- if (rejectUntrusted(req, res)) return
698
- const presets = ctx.get('agentPresets')
699
- const permissions = ctx.get('permissionPresets')
700
- let agentPresets = []
701
- try {
702
- const rows = presets === undefined ? [] : await presets.list()
703
- // Filter out invalid presets that would fail session creation.
704
- agentPresets = rows.filter((row) => row?.broken === undefined).map((row) => ({
705
- id: row.id, name: row.name || row.id, description: row.description,
706
- }))
707
- } catch (error) {
708
- ctx.logger?.warn?.(`dsh-kanban: agent profiles failed to load: ${error?.message}`)
709
- }
710
- let access = []
711
- let accessDefault = ''
712
- try {
713
- if (permissions !== undefined) {
714
- access = permissions.names.map((name) => permissions.optionOf(name))
715
- accessDefault = permissions.defaultPreset ?? ''
716
- }
717
- } catch (error) {
718
- ctx.logger?.warn?.(`dsh-kanban: access levels failed to load: ${error?.message}`)
719
- }
720
- reply(res, {
721
- agentPresets,
722
- agentPresetDefault: presets?.defaultId ?? '',
723
- access,
724
- accessDefault,
725
- })
726
- },
359
+ handler: handlers.handlePresets,
727
360
  }), 'dsh-kanban: agent profiles and auth scopes')
728
361
 
729
362
  ctx.effect(() => ctx.webServer.register({
730
363
  kind: 'exact',
731
364
  path: '/dsh-kanban/sessions',
732
- handler: (req, res) => {
733
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
734
- if (rejectUntrusted(req, res)) return
735
- reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
736
- },
365
+ handler: handlers.handleSessions,
737
366
  }), 'dsh-kanban: live sessions')
738
367
 
739
368
  ctx.effect(() => ctx.webServer.register({
740
369
  kind: 'exact',
741
370
  path: '/dsh-kanban/batch',
742
- handler: async (req, res) => {
743
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
744
- if (rejectUntrusted(req, res)) return
745
- try {
746
- const input = await readBody(req)
747
- const ids = Array.isArray(input.ids) ? input.ids : []
748
- const picked = ids.map((id) => store.getTask(id)).filter((task) => task !== undefined)
749
- const chosen = resolveModel({
750
- requested: input,
751
- fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
752
- })
753
- if (chosen === undefined) { writeJson(res, 400, { error: 'model-required' }); return }
754
- reply(res, await runBatch({
755
- agents: ctx.agents, store, tasks: picked, config: getConfig(),
756
- provider: chosen.provider, model: chosen.model,
757
- mintSessionId: () => SessionId(`kanban-batch-${randomUUID()}`),
758
- createMessage: createUserMessage,
759
- cwdOf: undefined, logger: ctx.logger, text: input.text,
760
- agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
761
- permission: typeof input.permission === 'string' ? input.permission : undefined,
762
- permissions: ctx.get('permissionPresets'),
763
- claimAs: await whoAmI(),
764
- }))
765
- } catch (error) {
766
- writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
767
- }
768
- },
371
+ handler: handlers.handleBatch,
769
372
  }), 'dsh-kanban: batch dispatch')
770
373
 
771
374
  ctx.effect(() => ctx.webServer.register({
772
375
  kind: 'exact',
773
376
  path: '/dsh-kanban/archive',
774
- handler: (req, res) => {
775
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
776
- if (rejectUntrusted(req, res)) return
777
- reply(res, listArchive({ store }))
778
- },
377
+ handler: handlers.handleArchive,
779
378
  }), 'dsh-kanban: archive')
780
379
 
781
380
  ctx.effect(() => ctx.webServer.register({
782
381
  kind: 'exact',
783
382
  path: '/dsh-kanban/sync',
784
- handler: async (req, res) => {
785
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
786
- if (rejectUntrusted(req, res)) return
787
- try {
788
- writeJson(res, 200, await runSync())
789
- } catch (error) {
790
- writeJson(res, 502, { error: 'sync-failed' })
791
- }
792
- },
383
+ handler: handlers.handleSync,
793
384
  }), 'dsh-kanban: manual sync')
794
385
 
795
386
  ctx.effect(() => ctx.webServer.register({
796
387
  kind: 'exact',
797
388
  path: '/dsh-kanban/webhook',
798
- handler: async (req, res) => {
799
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
800
- try {
801
- const raw = await readRaw(req)
802
- const isGithub = detectWebhookProvider(req.headers) === 'github'
803
- const secretRef = isGithub
804
- ? (getConfig().githubWebhookSecretRef || getConfig().webhookSecretRef)
805
- : getConfig().webhookSecretRef
806
- const secret = await resolveSecret(secretRef)
807
- const sig = isGithub ? req.headers['x-hub-signature-256'] : req.headers['x-gitea-signature']
808
- if (!verifySignature(secret, raw, sig)) {
809
- writeJson(res, 401, { error: 'bad-signature' })
810
- return
811
- }
812
- let payload
813
- try { payload = JSON.parse(raw.toString('utf8')) } catch { payload = undefined }
814
- const target = parseEvent(payload)
815
- if (target === undefined) { writeJson(res, 202, { ignored: true }); return }
816
-
817
- const activeGit = isGithub ? github : gitea
818
- const owner = isGithub
819
- ? (String(getConfig().githubOwner ?? '').trim() || target.owner)
820
- : await defaultOwner()
821
- const taken = target.owner === owner || isWatched({
822
- config: getConfig(), owner: target.owner, defaultOwner: owner, repo: target.repo,
823
- })
824
- ? await intakeAll({
825
- gitea: activeGit, store, config: getConfig(), owner, logger: ctx.logger,
826
- only: (pair) => pair.owner === target.owner && pair.repo === target.repo,
827
- })
828
- : { added: 0, skipped: 0, failed: 0 }
829
-
830
- const out = await syncAll({
831
- gitea: git, store, logger: ctx.logger,
832
- only: (t) => t.owner === target.owner && t.repo === target.repo
833
- && (target.issueNumber === undefined || t.issueNumber === target.issueNumber),
834
- })
835
- writeJson(res, 200, { ...out, taken })
836
- } catch (error) {
837
- writeJson(res, error?.status ?? 500, { error: 'webhook-failed' })
838
- }
839
- },
389
+ handler: handlers.handleWebhook,
840
390
  }), 'dsh-kanban: gitea webhook')
841
391
 
842
- // Agent tool definition. Default disabled; workflow skills explicitly enable it.
843
- ctx.effect(() => {
844
- let unregister
845
- const arm = () => {
846
- const wanted = getConfig().boardToolEnabled === true
847
- if (wanted === (unregister !== undefined)) return
848
- if (!wanted) { unregister?.(); unregister = undefined; return }
849
- unregister = registerBoardTool()
850
- }
851
-
852
- // Single setting switch controls both board tools.
853
- const registerBoardTool = () => {
854
- const off = [
855
- ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig, eventHub }))),
856
- ctx.tools.register(defineTool(boardPlanDefinition({ store, eventHub }))),
857
- ctx.tools.register(defineTool(boardChecklistDefinition({ store, eventHub }))),
858
- ctx.tools.register(defineTool(boardReportDefinition({ store }))),
859
- ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
860
- ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
861
- ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
862
- ctx.tools.register(defineTool(boardChecklistItemDefinition({ store, eventHub }))),
863
- ]
864
- return () => { for (const stop of off) stop?.() }
865
- }
866
-
867
- arm()
868
- rearmers.add(arm)
869
- return () => { rearmers.delete(arm); unregister?.() }
870
- }, 'dsh-kanban: board tool')
871
-
872
- /**
873
- * Return active agent state or undefined if session is inactive.
874
- */
875
- const liveOf = (sessionId) => {
876
- try {
877
- const a = ctx.agents?.get?.(sessionId);
878
- if (!a) return undefined;
879
- return {
880
- status: a.status || (typeof a.isIdle === 'function' && a.isIdle() ? 'idle' : 'running'),
881
- state: a.state || a.lifecycleState,
882
- step: a.step || a.currentStep || a.stateDetail || a.activity || '',
883
- activity: a.activity || a.statusDetail || '',
884
- };
885
- } catch {
886
- return undefined;
887
- }
888
- };
889
-
890
392
  ctx.effect(() => ctx.webServer.register({
891
393
  kind: 'exact',
892
394
  path: '/dsh-kanban/board',
893
- handler: async (req, res) => {
894
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
895
- if (rejectUntrusted(req, res)) return
896
- const cfg = getConfig()
897
- if (shouldRelay(cfg)) {
898
- const url = urlOf(req)
899
- const relayRes = await relayRequest({
900
- config: cfg,
901
- urlPath: '/dsh-kanban/board' + (url.search || ''),
902
- method: 'GET',
903
- headers: req.headers,
904
- })
905
- writeJson(res, relayRes.status, relayRes.body)
906
- return
907
- }
908
- const url = urlOf(req)
909
- reply(res, buildBoard({
910
- store,
911
- config: cfg,
912
- board: url.searchParams.get('board') || 'main',
913
- repo: url.searchParams.get('repo') || undefined,
914
- liveOf,
915
- sync: syncState.snapshot(),
916
- // Resolved project root passed with board payload for session launch modal.
917
- projectRoot: rootOf(cfg),
918
- costMeterAvailable: isCostMeterAvailable(ctx),
919
- }))
920
- },
395
+ handler: handlers.handleBoard,
921
396
  }), 'dsh-kanban: board route')
922
397
 
923
398
  ctx.effect(() => ctx.webServer.register({
924
399
  kind: 'exact',
925
400
  path: '/dsh-kanban/remote/test',
926
- handler: async (req, res) => {
927
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
928
- if (rejectUntrusted(req, res)) return
929
- const cfg = getConfig()
930
- const sshRes = await testRemoteConnection(cfg)
931
- const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
932
- writeJson(res, 200, {
933
- remoteMode: isRemoteMode(cfg),
934
- ssh: sshRes,
935
- server: serverRes,
936
- })
937
- },
401
+ handler: handlers.handleRemoteTest,
938
402
  }), 'dsh-kanban: remote connection test')
939
403
 
940
404
  ctx.effect(() => ctx.webServer.register({
941
405
  kind: 'exact',
942
406
  path: '/api/dsh-kanban/remote/test',
943
- handler: async (req, res) => {
944
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
945
- if (rejectUntrusted(req, res)) return
946
- const cfg = getConfig()
947
- const sshRes = await testRemoteConnection(cfg)
948
- const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
949
- writeJson(res, 200, {
950
- remoteMode: isRemoteMode(cfg),
951
- ssh: sshRes,
952
- server: serverRes,
953
- })
954
- },
407
+ handler: handlers.handleRemoteTest,
955
408
  }), 'dsh-kanban: remote connection test alias')
956
409
 
957
-
958
- // Snapshot endpoint handles both export and import.
959
- /**
960
- * Cached identity for Gitea client to avoid redundant API lookups on task claim.
961
- */
962
- let knownLogin
963
- const whoAmI = async () => {
964
- if (knownLogin !== undefined) return knownLogin
965
- try {
966
- knownLogin = await gitea.me()
967
- } catch (error) {
968
- knownLogin = ''
969
- }
970
- return knownLogin
971
- }
972
-
973
410
  ctx.effect(() => ctx.webServer.register({
974
411
  kind: 'exact',
975
412
  path: '/dsh-kanban/metrics',
976
- handler: (req, res) => {
977
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
978
- if (rejectUntrusted(req, res)) return
979
- const url = urlOf(req)
980
- const days = Number(url.searchParams.get('staleDays'))
981
- reply(res, boardMetrics({
982
- store,
983
- board: url.searchParams.get('board') || 'main',
984
- staleDays: Number.isFinite(days) && days > 0 ? days : 14,
985
- }))
986
- },
413
+ handler: handlers.handleMetrics,
987
414
  }), 'dsh-kanban: board metrics')
988
415
 
989
416
  ctx.effect(() => ctx.webServer.register({
990
417
  kind: 'exact',
991
418
  path: '/dsh-kanban/whoami',
992
- // Empty login indicates unconfigured or unreachable Gitea; client omits "Mine" filter.
993
- handler: async (req, res) => {
994
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
995
- if (rejectUntrusted(req, res)) return
996
- reply(res, { login: await whoAmI() })
997
- },
419
+ handler: handlers.handleWhoami,
998
420
  }), 'dsh-kanban: whoami')
999
421
 
1000
422
  ctx.effect(() => ctx.webServer.register({
1001
423
  kind: 'exact',
1002
424
  path: '/dsh-kanban/export.csv',
1003
- handler: async (req, res) => {
1004
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1005
- if (rejectUntrusted(req, res)) return
1006
- const u = urlOf(req)
1007
- const board = u.searchParams.get('board') || 'main'
1008
- const csv = exportTasksCsv({ store, board })
1009
- res.writeHead(200, {
1010
- 'Content-Type': 'text/csv; charset=utf-8',
1011
- 'Content-Disposition': `attachment; filename="kanban-${board}-${new Date().toISOString().slice(0, 10)}.csv"`,
1012
- 'Cache-Control': 'no-store',
1013
- })
1014
- res.end(csv)
1015
- },
425
+ handler: handlers.handleExportCsv,
1016
426
  }), 'dsh-kanban: csv export')
1017
427
 
1018
428
  ctx.effect(() => ctx.webServer.register({
1019
429
  kind: 'exact',
1020
430
  path: '/dsh-kanban/events',
1021
- handler: (req, res) => {
1022
- eventHub.handleSseRequest(req, res, { isTrustedRequest: isTrusted })
1023
- },
431
+ handler: handlers.handleEvents,
1024
432
  }), 'dsh-kanban: sse events stream')
1025
433
 
1026
- // Clean up SSE client connections and heartbeat timer on plugin disposal (#282)
1027
434
  if (typeof ctx.on === 'function') {
1028
435
  ctx.on('dispose', () => {
1029
436
  eventHub.closeAll()
@@ -1033,600 +440,49 @@ export function apply(ctx, config) {
1033
440
  ctx.effect(() => ctx.webServer.register({
1034
441
  kind: 'exact',
1035
442
  path: '/dsh-kanban/templates',
1036
- handler: (req, res) => {
1037
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1038
- if (rejectUntrusted(req, res)) return
1039
- const u = new URL(req.url, 'http://localhost')
1040
- const tid = u.searchParams.get('id')
1041
- if (tid) {
1042
- const tmpl = getTemplateById(tid)
1043
- if (!tmpl) { writeJson(res, 404, { error: 'template-not-found' }); return }
1044
- reply(res, { template: tmpl })
1045
- return
1046
- }
1047
- reply(res, { templates: TASK_TEMPLATES })
1048
- },
443
+ handler: handlers.handleTemplates,
1049
444
  }), 'dsh-kanban: task templates')
1050
445
 
1051
446
  ctx.effect(() => ctx.webServer.register({
1052
447
  kind: 'exact',
1053
448
  path: '/dsh-kanban/snapshot',
1054
- handler: async (req, res) => {
1055
- if (rejectUntrusted(req, res)) return
1056
- if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
1057
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1058
- try {
1059
- const input = await readBody(req)
1060
- const impResult = importBoard({ store, input })
1061
- if (impResult?.imported > 0) {
1062
- eventHub.broadcast('task:change', { action: 'imported', count: impResult.imported })
1063
- }
1064
- reply(res, impResult)
1065
- } catch {
1066
- writeJson(res, 400, { error: 'bad-request' })
1067
- }
1068
- },
449
+ handler: handlers.handleSnapshot,
1069
450
  }), 'dsh-kanban: board snapshot')
1070
451
 
1071
452
  ctx.effect(() => ctx.webServer.register({
1072
- // Prefix registered without trailing slash for sub-path routing.
1073
453
  kind: 'prefix',
1074
454
  path: '/dsh-kanban/task',
1075
- handler: async (req, res) => {
1076
- const pathname = urlOf(req).pathname
1077
-
1078
- // Path without ID handles local task creation.
1079
- if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
1080
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1081
- if (rejectUntrusted(req, res)) return
1082
- try {
1083
- const resObj = createTask({ store, input: await readBody(req) })
1084
- if (resObj?.task) {
1085
- eventHub.broadcast('task:change', { taskId: resObj.task.id, action: 'created' })
1086
- }
1087
- reply(res, resObj)
1088
- } catch (error) {
1089
- writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1090
- }
1091
- return
1092
- }
1093
-
1094
- const parsed = parseTaskPath(pathname)
1095
- if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
1096
- const { id, action } = parsed
1097
-
1098
- if (rejectUntrusted(req, res)) return
1099
-
1100
- if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
1101
- if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
1102
-
1103
- // Generate start prompt on server for template consistency.
1104
- if (req.method === 'GET' && action === 'message') {
1105
- const task = store.getTask(id)
1106
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1107
- writeJson(res, 200, { text: buildStartMessage(task, getConfig()) })
1108
- return
1109
- }
1110
- if (req.method === 'DELETE' && action === undefined) {
1111
- const resObj = deleteTask({ store, id })
1112
- if (resObj?.deleted) {
1113
- eventHub.broadcast('task:change', { taskId: id, action: 'deleted' })
1114
- }
1115
- reply(res, resObj)
1116
- return
1117
- }
1118
- // Card fields update: title, body, labels, priority. Priority is board-local.
1119
- if (req.method === 'POST' && action === 'update') {
1120
- try {
1121
- const input = await readBody(req)
1122
- const resObj = updateTask({ store, id, input })
1123
- if (resObj?.task) {
1124
- eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1125
- }
1126
- reply(res, resObj)
1127
- } catch {
1128
- writeJson(res, 400, { error: 'bad-request' })
1129
- }
1130
- return
1131
- }
1132
- if (req.method === 'POST' && action === 'refresh') {
1133
- reply(res, await refreshTask({ gitea: git, store, id }))
1134
- return
1135
- }
1136
- if (req.method === 'GET' && action === 'workflow-status') {
1137
- const task = store.getTask(id)
1138
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1139
- const active = workflowBridge.activeRuns.get(id)
1140
- reply(res, {
1141
- active: Boolean(active),
1142
- runId: active?.runId || task.workflowRunId || '',
1143
- role: active?.role || '',
1144
- status: task.workflowStatus || (active ? 'running' : 'idle'),
1145
- })
1146
- return
1147
- }
1148
- if (req.method === 'POST' && action === 'workflow-cancel') {
1149
- try {
1150
- const input = await readBody(req)
1151
- const reason = typeof input?.reason === 'string' ? input.reason : 'cancelled-by-user'
1152
- const result = await workflowBridge.cancelTaskWorkflow(id, reason)
1153
- syncPower()
1154
- reply(res, result)
1155
- } catch (error) {
1156
- writeJson(res, 500, { error: error?.message || 'cancel-failed' })
1157
- }
1158
- return
1159
- }
1160
- if (req.method === 'POST' && (action === 'start' || action === 'workflow-start')) {
1161
- try {
1162
- const input = await readBody(req)
1163
- const task = store.getTask(id)
1164
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1165
- const picked = resolveModel({
1166
- requested: input,
1167
- fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
1168
- })
1169
- if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
1170
-
1171
- if (action === 'workflow-start' || input.workflow === true || input.mode === 'workflow') {
1172
- const started = await workflowBridge.startTaskWorkflow({
1173
- task,
1174
- role: typeof input.role === 'string' ? input.role : (typeof input.agentPreset === 'string' ? input.agentPreset : undefined),
1175
- provider: picked.provider,
1176
- model: picked.model,
1177
- mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1178
- gitRunner: defaultGitRunner,
1179
- workspaceRegistry: ctx.get('workspaceRegistry'),
1180
- claimAs: await whoAmI(),
1181
- permission: typeof input.permission === 'string' ? input.permission : undefined,
1182
- permissions: ctx.get('permissionPresets'),
1183
- cwdOf: rootOf(getConfig()),
1184
- text: typeof input.text === 'string' ? input.text : undefined,
1185
- })
1186
- syncPower()
1187
- reply(res, started)
1188
- return
1189
- }
1190
-
1191
- const started = await runTask({
1192
- agents: ctx.agents,
1193
- store,
1194
- task,
1195
- config: getConfig(),
1196
- provider: picked.provider,
1197
- model: picked.model,
1198
- // Mint session ID lazily to avoid dangling IDs on resume.
1199
- mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1200
- createMessage: createUserMessage,
1201
- logger: ctx.logger,
1202
- text: typeof input.text === 'string' ? input.text : undefined,
1203
- agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
1204
- permission: typeof input.permission === 'string' ? input.permission : undefined,
1205
- permissions: ctx.get('permissionPresets'),
1206
- workspaceRegistry: ctx.get('workspaceRegistry'),
1207
- claimAs: await whoAmI(),
1208
- })
1209
- syncPower()
1210
- reply(res, started)
1211
- } catch (error) {
1212
- ctx.logger?.warn?.(`dsh-kanban: task launch failed: ${error?.message}`)
1213
- // Explicit permission errors are returned with status and key.
1214
- writeJson(res, error?.status ?? 500, { error: error?.key ?? error?.message ?? 'start-failed' })
1215
- }
1216
- return
1217
- }
1218
-
1219
- try {
1220
- const input = await readBody(req)
1221
- if (req.method === 'POST' && action === 'move') {
1222
- const was = store.getTask(id)
1223
- if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1224
-
1225
- // Automated Verification Gate (#372): intercept transition to review
1226
- if (input.column === 'review' && was.column !== 'review') {
1227
- const gateResult = await verifyAndRecordGate({
1228
- task: was,
1229
- config: getConfig(),
1230
- store,
1231
- agents: ctx.agents,
1232
- createMessage: createUserMessage,
1233
- })
1234
- if (!gateResult.allowed) {
1235
- writeJson(res, 422, {
1236
- error: 'verification-failed',
1237
- command: gateResult.verification.command,
1238
- exitCode: gateResult.verification.exitCode,
1239
- log: gateResult.verification.logSnippet,
1240
- task: store.getTask(id),
1241
- })
1242
- return
1243
- }
1244
- }
1245
-
1246
- // Manual move dispatches stop command to agent prior to updating card column.
1247
- const acted = was !== undefined && input.column !== was.column
1248
- ? dispatchMove({
1249
- agents: ctx.agents, task: was, column: input.column,
1250
- kind: store.boardKind(was.board),
1251
- createMessage: createUserMessage, logger: ctx.logger,
1252
- }).acted
1253
- : undefined
1254
- const out = applyMove({
1255
- store, config: getConfig(), id,
1256
- column: input.column, beforeId: input.beforeId, afterId: input.afterId,
1257
- detail: acted === undefined ? '' : (MOVE_DETAIL[acted] ?? ''),
1258
- })
1259
- if (out?.task !== undefined) {
1260
- eventHub.broadcast('task:change', { taskId: id, action: 'moved', toCol: out.task.column })
1261
- queueOutbound(id, out.task.column)
1262
- if (acted !== undefined) out.command = acted
1263
- if (input.column === 'cleanup' && was?.worktree) {
1264
- const dirty = await checkWorktreeDirty({ worktreePath: was.worktree })
1265
- if (dirty.dirty) {
1266
- out.worktreeWarning = 'worktree-dirty'
1267
- out.dirtyFiles = dirty.files
1268
- }
1269
- }
1270
- }
1271
- reply(res, out)
1272
- return
1273
- }
1274
- if (req.method === 'POST' && action === 'cleanup-worktree') {
1275
- const resObj = await handleCleanupWorktree({
1276
- store, task: store.getTask(id),
1277
- workspaceRegistry: ctx.get('workspaceRegistry'),
1278
- force: input?.force === true,
1279
- cleanupTaskWorktree,
1280
- gitRunner: getGitRunner(),
1281
- config: getConfig(),
1282
- repoDir: rootOf(getConfig()).path,
1283
- cwdOf: () => rootOf(getConfig()).path,
1284
- })
1285
- writeJson(res, resObj.status, resObj.body)
1286
- return
1287
- }
1288
- if (req.method === 'POST' && action === 'discard') {
1289
- const resObj = await handleTaskDiscard({
1290
- task: store.getTask(id),
1291
- file: input?.file,
1292
- discardTaskFile,
1293
- gitRunner: getGitRunner(),
1294
- })
1295
- if (resObj?.body?.success) {
1296
- eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
1297
- }
1298
- writeJson(res, resObj.status, resObj.body)
1299
- return
1300
- }
1301
- if (req.method === 'POST' && (action === 'nudge' || (input && input.action === 'nudge'))) {
1302
- const resObj = await handleTaskNudge({
1303
- store,
1304
- id,
1305
- prompt: input?.prompt || input?.text,
1306
- agents: ctx.agents,
1307
- createMessage: createUserMessage,
1308
- eventHub,
1309
- })
1310
- writeJson(res, resObj.status, resObj.body)
1311
- return
1312
- }
1313
- if (req.method === 'GET' && action === 'diff') {
1314
- const resObj = await handleTaskDiff({
1315
- task: store.getTask(id),
1316
- getTaskDiff: (opts) => getTaskDiff({ ...opts, gitRunner: getGitRunner() }),
1317
- getTaskDiffInspector: (opts) => getTaskDiffInspector({ ...opts, gitRunner: getGitRunner() }),
1318
- })
1319
- writeJson(res, resObj.status, resObj.body)
1320
- return
1321
- }
1322
- if (req.method === 'GET' && action === 'commits') {
1323
- const resObj = await handleTaskCommits({
1324
- task: store.getTask(id),
1325
- getTaskCommits: (opts) => getTaskCommits({ ...opts, gitRunner: getGitRunner() }),
1326
- })
1327
- writeJson(res, resObj.status, resObj.body)
1328
- return
1329
- }
1330
- if (req.method === 'POST' && action === 'merge') {
1331
- const resObj = await handleTaskMerge({
1332
- store, task: store.getTask(id),
1333
- targetBranch: input?.targetBranch,
1334
- workspaceRegistry: ctx.get('workspaceRegistry'),
1335
- mergeTaskWorktree,
1336
- gitRunner: getGitRunner(),
1337
- })
1338
- writeJson(res, resObj.status, resObj.body)
1339
- return
1340
- }
1341
- if (req.method === 'GET' && action === 'worktree-status') {
1342
- const resObj = await handleTaskWorktreeStatus({ task: store.getTask(id), checkWorktreeDirty })
1343
- writeJson(res, resObj.status, resObj.body)
1344
- return
1345
- }
1346
- if (req.method === 'GET' && action === 'subtasks') {
1347
- const resObj = handleGetSubtasks({ store, task: store.getTask(id) })
1348
- writeJson(res, resObj.status, resObj.body)
1349
- return
1350
- }
1351
- if (req.method === 'POST' && action === 'triage') {
1352
- const resObj = await handleTriageTask({ store, gitea: git, task: store.getTask(id), input })
1353
- writeJson(res, resObj.status, resObj.body)
1354
- return
1355
- }
1356
- if (req.method === 'POST' && (action === 'subtask' || action === 'subtasks')) {
1357
- const resObj = await handleCreateSubtask({ store, gitea: git, task: store.getTask(id), input })
1358
- writeJson(res, resObj.status, resObj.body)
1359
- return
1360
- }
1361
- if (req.method === 'POST' && action === 'checklist-toggle') {
1362
- const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
1363
- if (resObj?.body?.task) {
1364
- eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1365
- }
1366
- writeJson(res, resObj.status, resObj.body)
1367
- return
1368
- }
1369
- if (req.method === 'POST' && action === 'pr') {
1370
- const resObj = await handleCreateTaskPr({ store, gitea: git, task: store.getTask(id), input })
1371
- writeJson(res, resObj.status, resObj.body)
1372
- return
1373
- }
1374
- if (req.method === 'POST' && action === 'checklist') {
1375
- const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
1376
- if (resObj?.body?.task) {
1377
- eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1378
- }
1379
- writeJson(res, resObj.status, resObj.body)
1380
- return
1381
- }
1382
- if (req.method === 'POST' && action === 'report') {
1383
- const resObj = handleTaskReport({ store, task: store.getTask(id), input })
1384
- if (resObj?.body?.task || resObj?.body?.report) {
1385
- eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1386
- }
1387
- writeJson(res, resObj.status, resObj.body)
1388
- return
1389
- }
1390
- if (req.method === 'GET' && action === 'comments') {
1391
- const resObj = handleGetTaskComments({ task: store.getTask(id) })
1392
- writeJson(res, resObj.status, resObj.body)
1393
- return
1394
- }
1395
- if (req.method === 'POST' && action === 'comments') {
1396
- const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
1397
- if (resObj?.body?.task || resObj?.status === 200) {
1398
- eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
1399
- }
1400
- writeJson(res, resObj.status, resObj.body)
1401
- return
1402
- }
1403
- if (req.method === 'POST' && action === 'accept') {
1404
- const resObj = handleAcceptTask({ store, task: store.getTask(id) })
1405
- writeJson(res, resObj.status, resObj.body)
1406
- return
1407
- }
1408
- if (req.method === 'POST' && action === 'reject') {
1409
- const resObj = handleRejectTask({ store, task: store.getTask(id), input })
1410
- writeJson(res, resObj.status, resObj.body)
1411
- return
1412
- }
1413
- if (req.method === 'POST' && action === 'resume') {
1414
- const task = store.getTask(id)
1415
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1416
- const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
1417
- const picked = resolveModel({
1418
- requested: { model: input?.model || task.model, provider: input?.provider || task.provider },
1419
- fallback,
1420
- })
1421
- if (picked.error) { reply(res, picked); return }
1422
- const out = await runTask({
1423
- agents: ctx.agents,
1424
- store,
1425
- task,
1426
- config: getConfig(),
1427
- provider: picked.provider,
1428
- model: picked.model,
1429
- mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1430
- createMessage: createUserMessage,
1431
- cwdOf: undefined,
1432
- logger: ctx.logger,
1433
- text: typeof input?.text === 'string' ? input.text : undefined,
1434
- agentPreset: input?.agentPreset || task.agentPreset,
1435
- permission: input?.permission || task.permission,
1436
- permissions: ctx.get('permissionPresets'),
1437
- claimAs: await whoAmI(),
1438
- workspaceRegistry: ctx.get('workspaceRegistry'),
1439
- resume: true,
1440
- })
1441
- syncPower()
1442
- reply(res, out)
1443
- return
1444
- }
1445
- if (req.method === 'POST' && action === 'confirm-permission') {
1446
- reply(res, confirmPermission({ store, id }))
1447
- return
1448
- }
1449
- if (req.method === 'POST' && action === 'progress-dump') {
1450
- const resObj = handleProgressDump({ store, task: store.getTask(id), input, formatProgressDump })
1451
- writeJson(res, resObj.status, resObj.body)
1452
- return
1453
- }
1454
- if (req.method === 'POST' && action === 'stop') {
1455
- const task = store.getTask(id)
1456
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1457
- const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
1458
- if (out.acted === 'stopped') {
1459
- const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
1460
- if (latestRun && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
1461
- store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
1462
- }
1463
- syncPower()
1464
- // Stop event recorded in transition history.
1465
- store.addTransition({
1466
- taskId: task.id, fromCol: task.column, toCol: task.column,
1467
- source: 'manual', detail: STOP_DETAIL.stopped,
1468
- })
1469
- }
1470
- reply(res, out)
1471
- return
1472
- }
1473
- if (req.method === 'POST' && action === 'assign') {
1474
- const was = store.getTask(id)
1475
- if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1476
- // Empty login unassigns; 'me' resolves authenticated token identity.
1477
- let login = typeof input.login === 'string' ? input.login.trim() : ''
1478
- if (login === 'me') {
1479
- login = await whoAmI()
1480
- if (login === '') { writeJson(res, 502, { error: 'gitea-unreachable' }); return }
1481
- }
1482
- const out = assignTask({ store, id, login })
1483
- if (out.task !== undefined && config.pushToGitea !== false) {
1484
- // Queue assignment update to outbox for offline resilience.
1485
- outbox.push(id, planAssign(out.task, login))
1486
- }
1487
- reply(res, out)
1488
- return
1489
- }
1490
- if (req.method === 'POST' && action === 'archive') {
1491
- const resObj = setArchived({ store, id, archived: true })
1492
- if (resObj?.task) {
1493
- eventHub.broadcast('task:change', { taskId: id, action: 'archived' })
1494
- }
1495
- reply(res, resObj)
1496
- return
1497
- }
1498
- if (req.method === 'POST' && action === 'restore') {
1499
- const resObj = setArchived({ store, id, archived: false })
1500
- if (resObj?.task) {
1501
- eventHub.broadcast('task:change', { taskId: id, action: 'restored' })
1502
- }
1503
- reply(res, resObj)
1504
- return
1505
- }
1506
- if (req.method === 'POST' && action === 'queue') {
1507
- reply(res, await queueTask({
1508
- agents: ctx.agents, store, task: store.getTask(id),
1509
- sessionId: input.sessionId, config: getConfig(),
1510
- createMessage: createUserMessage, text: input.text,
1511
- }))
1512
- return
1513
- }
1514
- if (req.method === 'POST' && action === 'unqueue') {
1515
- reply(res, unqueueTask({ store, task: store.getTask(id) }))
1516
- return
1517
- }
1518
- if (req.method === 'POST' && action === 'note') {
1519
- const out = appendNote({ store, id, input })
1520
- if (out?.task || out?.text) {
1521
- eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
1522
- }
1523
- reply(res, out)
1524
- return
1525
- }
1526
- if (req.method === 'PATCH' && (action === undefined || action === '')) {
1527
- const resObj = updateTask({ store, id, input })
1528
- if (resObj?.task) {
1529
- eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1530
- }
1531
- reply(res, resObj)
1532
- return
1533
- }
1534
- writeJson(res, 405, { error: 'method-not-allowed' })
1535
- } catch (error) {
1536
- writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1537
- }
1538
- },
455
+ handler: taskHandler,
1539
456
  }), 'dsh-kanban: task routes')
1540
457
 
1541
- // Check Gitea readiness per request to handle dynamically updated credentials.
1542
458
  ctx.effect(() => ctx.webServer.register({
1543
459
  kind: 'exact',
1544
460
  path: '/dsh-kanban/gitea/repos',
1545
- handler: async (req, res) => {
1546
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1547
- if (rejectUntrusted(req, res)) return
1548
- const url = urlOf(req)
1549
- reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1550
- },
461
+ handler: handlers.handleGiteaRepos,
1551
462
  }), 'dsh-kanban: repo search')
1552
463
 
1553
464
  ctx.effect(() => ctx.webServer.register({
1554
465
  kind: 'exact',
1555
466
  path: '/dsh-kanban/gitea/issues',
1556
- handler: async (req, res) => {
1557
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1558
- if (rejectUntrusted(req, res)) return
1559
- const url = urlOf(req)
1560
- reply(res, await listImportable({
1561
- gitea: git,
1562
- store,
1563
- owner: url.searchParams.get('owner') || undefined,
1564
- repo: url.searchParams.get('repo') || undefined,
1565
- board: url.searchParams.get('board') || 'main',
1566
- }))
1567
- },
467
+ handler: handlers.handleGiteaIssues,
1568
468
  }), 'dsh-kanban: importable issues')
1569
469
 
1570
470
  ctx.effect(() => ctx.webServer.register({
1571
471
  kind: 'exact',
1572
472
  path: '/dsh-kanban/import',
1573
- handler: async (req, res) => {
1574
- if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1575
- if (rejectUntrusted(req, res)) return
1576
- try {
1577
- const input = await readBody(req)
1578
- reply(res, await importIssue({
1579
- gitea: git, store,
1580
- owner: input.owner, repo: input.repo, issueNumber: input.issueNumber,
1581
- board: input.board ?? 'main', column: input.column ?? 'backlog',
1582
- forge: input.forge || input.provider,
1583
- }))
1584
- } catch (error) {
1585
- writeJson(res, error?.status ?? 500, { error: 'bad-request' })
1586
- }
1587
- },
473
+ handler: handlers.handleGiteaImport,
1588
474
  }), 'dsh-kanban: issue import')
1589
475
 
1590
- // Providers returned immediately; models queried on-demand for selected provider.
1591
476
  ctx.effect(() => ctx.webServer.register({
1592
477
  kind: 'exact',
1593
478
  path: '/dsh-kanban/models',
1594
- handler: async (req, res) => {
1595
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1596
- if (rejectUntrusted(req, res)) return
1597
- const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
1598
- const llmService = ctx.get('llm')
1599
- let providers = []
1600
- try {
1601
- providers = ((llmService?.listProviders?.() ?? []).map((p) => ({ id: p.id, name: p.name ?? p.id })))
1602
- } catch { providers = [] }
1603
-
1604
- const wanted = urlOf(req).searchParams.get('provider') || ''
1605
- if (wanted === '') { writeJson(res, 200, { current, providers, models: [] }); return }
1606
-
1607
- try {
1608
- const rows = await llmService?.listModels?.(wanted)
1609
- writeJson(res, 200, {
1610
- current, providers,
1611
- models: (rows ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id })),
1612
- })
1613
- } catch {
1614
- // Provider errors return empty model list while preserving selected provider.
1615
- writeJson(res, 200, { current, providers, models: [] })
1616
- }
1617
- },
479
+ handler: handlers.handleModels,
1618
480
  }), 'dsh-kanban: providers and models')
1619
481
 
1620
482
  ctx.effect(() => ctx.webServer.register({
1621
483
  kind: 'prefix',
1622
484
  path: '/dsh-kanban/session',
1623
- handler: (req, res) => {
1624
- if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
1625
- if (rejectUntrusted(req, res)) return
1626
- const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1627
- if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1628
- reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
1629
- },
485
+ handler: handlers.handleSessionTask,
1630
486
  }), 'dsh-kanban: task route by session')
1631
487
 
1632
488
  ctx.effect(() => registerPluginUpdater(ctx, {
@@ -1634,5 +490,4 @@ export function apply(ctx, config) {
1634
490
  packageName: '@goodandready/dsh-kanban',
1635
491
  manifestUrl: new URL('../package.json', import.meta.url),
1636
492
  }), 'dsh-kanban: plugin updater route')
1637
-
1638
493
  }