@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/client.js +170 -379
- package/lib/config.js +31 -1
- package/lib/index.js +120 -1205
- package/lib/remote-relay.js +149 -0
- package/lib/remote-runner.js +164 -0
- package/lib/routes/board.js +282 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +345 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +211 -0
- package/lib/routes.js +71 -1206
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +530 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +9 -2
- package/lib/watchdog.js +169 -0
- package/lib/worktree.js +35 -2
- package/package.json +1 -1
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 {
|
|
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 {
|
|
70
|
-
import {
|
|
71
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
107
|
-
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'
|
|
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
|
-
|
|
296
|
-
|
|
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(
|
|
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
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|