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