@goodandready/dsh-kanban 0.2.34 → 0.2.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/board-tool.js +8 -0
- package/lib/client.js +3800 -3587
- package/lib/config.js +10 -0
- package/lib/dependencies.js +175 -0
- package/lib/index.js +110 -1255
- package/lib/routes/board.js +286 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +418 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +237 -0
- package/lib/routes.js +73 -1209
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +569 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +23 -2
- package/lib/time-machine.js +214 -0
- package/lib/watchdog.js +169 -0
- package/package.json +1 -1
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
|
|
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 {
|
|
72
|
-
import {
|
|
73
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
109
|
-
import {
|
|
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
|
-
|
|
299
|
-
|
|
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(
|
|
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
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|