@goodandready/dsh-kanban 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,764 @@
1
+ // dsh-kanban — доска задач для DeepSeek Harness.
2
+ //
3
+ // Плагин состоит из двух половин: этот модуль — серверная, `lib/client.js` —
4
+ // браузерная. Имя пакета `@goodandready/dsh-kanban` обязано совпадать в трёх
5
+ // местах: `package.json`, `cordis.patch.yml` и `load({ id })` в client.js.
6
+ // Расхождение НЕ даёт ошибки в журнале — интерфейс просто не появляется.
7
+ //
8
+ // Чистая логика вынесена в модули без зависимостей (`config.js`, `order.js`,
9
+ // `routes.js`) и проверяется тестами без харнесса. Здесь остаётся обвязка
10
+ // cordis: настройки, хранилище и маршруты.
11
+
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { randomUUID } from 'node:crypto'
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+ import { SessionId } from '@deepseek-ai/dsh-session'
18
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
19
+ import { CONFIG_DEFAULTS, CONFIG_HINTS, withDefaults, rootOf } from './config.js'
20
+ import { openStore } from './store.js'
21
+ import {
22
+ MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
23
+ buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
+ taskBody,
25
+ taskLog, taskBySession,
26
+ } from './routes.js'
27
+ import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
28
+ import {
29
+ runTask, resolveModel, buildStartMessage,
30
+ runBatch, queueTask, unqueueTask, liveSessions,
31
+ } from './launcher.js'
32
+ import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
33
+ import { createGiteaClient } from './gitea.js'
34
+ import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
35
+ import { isWatched } from './intake.js'
36
+ import { handleSessionEvent } from './lifecycle.js'
37
+ import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
38
+ import { verifySignature, parseEvent } from './webhook.js'
39
+ import { planOutbound, createOutbox } from './outbound.js'
40
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
41
+
42
+ export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
43
+
44
+ /** Стабильное имя плагина cordis (строка в патче сборки). */
45
+ export const name = 'dsh-kanban'
46
+
47
+ /**
48
+ * Службы добавляются там, где впервые нужны: `gitea` — вместе с импортом
49
+ * задач, `agents`/`sessions` — вместе с запуском работы. Объявленная, но
50
+ * отсутствующая служба не даёт плагину загрузиться вовсе.
51
+ */
52
+ export const inject = ['settings', 'webServer', 'agents', 'agentDefaultModel', 'credentials', 'llm', 'tools']
53
+
54
+ /** Пространство настроек; то же имя продублировано в браузерной половине. */
55
+ // Пространство настроек — просто строка.
56
+ //
57
+ // В 0.1.1 ядро отдавало помощника `settingsNamespace`, который проверял имя по
58
+ // образцу и возвращал ту же строку с типом-меткой. В 0.1.2-alpha.2 помощника
59
+ // убрали, и импорт его роняет ЗАГРУЗКУ ВСЕГО ПРОФИЛЯ, а не один плагин.
60
+ // Проверять здесь нечего: имя — постоянная этого файла, а не пользовательский
61
+ // ввод. На обеих версиях ядра значение одно и то же.
62
+ export const SETTINGS_NAMESPACE = 'dsh-kanban'
63
+
64
+ /**
65
+ * Схема собирается из значений по умолчанию, чтобы список полей жил в одном
66
+ * месте. Только скаляры: клиентский API настроек пишет ТОЛЬКО скалярные поля,
67
+ * и словарь или массив здесь сделал бы карточку нередактируемой.
68
+ */
69
+ export const Config = z.object(Object.fromEntries(
70
+ Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
71
+ // Узел схемы выбирается по типу умолчания. Пропустить здесь булево значит
72
+ // объявить его строкой — и плагин не загрузится вовсе с «invalid config».
73
+ const node = typeof fallback === 'number' ? z.number()
74
+ : typeof fallback === 'boolean' ? z.boolean()
75
+ : z.string()
76
+ return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
77
+ }),
78
+ ))
79
+
80
+ /** Каталог хранилища. Абсолютных путей в коде нет — только переменная и дом. */
81
+ export function storeDir() {
82
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'kanban')
83
+ }
84
+
85
+ function writeJson(res, code, body) {
86
+ try {
87
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
88
+ res.end(JSON.stringify(body))
89
+ } catch { /* сокет закрыт */ }
90
+ }
91
+
92
+ /** Ответ обработчика: либо объект с `error` и `status`, либо полезная нагрузка. */
93
+ function reply(res, out) {
94
+ if (out && typeof out === 'object' && typeof out.error === 'string') {
95
+ writeJson(res, out.status ?? 400, { error: out.error })
96
+ return
97
+ }
98
+ writeJson(res, 200, out)
99
+ }
100
+
101
+ function readBody(req) {
102
+ return new Promise((resolve, reject) => {
103
+ const chunks = []
104
+ let size = 0
105
+ req.on('data', (c) => {
106
+ size += c.length
107
+ if (size > MAX_BODY_BYTES) {
108
+ reject(Object.assign(new Error('тело запроса слишком велико'), { status: 413 }))
109
+ req.destroy()
110
+ return
111
+ }
112
+ chunks.push(c)
113
+ })
114
+ req.on('end', () => {
115
+ const raw = Buffer.concat(chunks).toString('utf8')
116
+ if (raw.trim() === '') { resolve({}); return }
117
+ try { resolve(JSON.parse(raw)) } catch { reject(Object.assign(new Error('тело запроса не JSON'), { status: 400 })) }
118
+ })
119
+ req.on('error', reject)
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Сырое тело запроса.
125
+ *
126
+ * Подпись вебхука считается по БАЙТАМ доставки. Разобрать JSON и собрать его
127
+ * обратно — значит получить другую последовательность байт и не сойтись в
128
+ * подписи на пустом месте.
129
+ */
130
+ function readRaw(req) {
131
+ return new Promise((resolve, reject) => {
132
+ const chunks = []
133
+ let size = 0
134
+ req.on('data', (c) => {
135
+ size += c.length
136
+ if (size > MAX_BODY_BYTES) {
137
+ reject(Object.assign(new Error('тело запроса слишком велико'), { status: 413 }))
138
+ req.destroy()
139
+ return
140
+ }
141
+ chunks.push(c)
142
+ })
143
+ req.on('end', () => resolve(Buffer.concat(chunks)))
144
+ req.on('error', reject)
145
+ })
146
+ }
147
+
148
+ function urlOf(req) {
149
+ return new URL(req.url ?? '/', 'http://localhost')
150
+ }
151
+
152
+ export function apply(ctx, config) {
153
+ let effective = withDefaults(config)
154
+ const getConfig = () => effective
155
+
156
+ // Часть настроек управляет живыми подписками: промежутком сверки и наличием
157
+ // инструмента у агента. Прочитать их один раз при загрузке — значит сделать
158
+ // переключатель в карточке настроек безответным до перезапуска харнесса.
159
+ const rearmers = new Set()
160
+ const rearm = () => {
161
+ for (const again of rearmers) {
162
+ try { again() } catch (error) { ctx.logger?.warn?.(`dsh-kanban: перевзвод не удался: ${error?.message}`) }
163
+ }
164
+ }
165
+
166
+ ctx.inject(['settings'], (sctx) => {
167
+ const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
168
+ const read = () => withDefaults(scope.get() ?? effective)
169
+ effective = read()
170
+ ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: слежение за настройками')
171
+ })
172
+
173
+ // Клиент Gitea свой: токен всё равно живёт в службе учётных данных ядра, а
174
+ // шесть вызовов REST дешевле зависимости от чужого выпуска. Значение токена
175
+ // наружу не отдаётся — в настройках только ИМЯ учётной записи.
176
+ const resolveSecret = async (name) => {
177
+ if (typeof name !== 'string' || name.trim() === '') return ''
178
+ try {
179
+ const resolved = await ctx.credentials.resolve(credentialRef(name.trim()))
180
+ return resolved?.value ?? ''
181
+ } catch {
182
+ return ''
183
+ }
184
+ }
185
+
186
+ const gitea = createGiteaClient({
187
+ getConfig,
188
+ resolveToken: resolveSecret,
189
+ })
190
+
191
+ // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
192
+ // выгрузка плагина обязана отпустить файл базы.
193
+ let store
194
+ ctx.effect(() => {
195
+ store = openStore({ dir: storeDir() })
196
+ return () => { try { store.close() } catch { /* уже закрыта */ } }
197
+ }, 'dsh-kanban: хранилище задач')
198
+
199
+ // Признак ожидания по событиям сессии. Задача ищется по идентификатору
200
+ // сессии; сессий не с доски большинство, и для них здесь ничего не
201
+ // происходит — это штатный ход, а не пропуск.
202
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
203
+ try {
204
+ handleSessionEvent({ store, sessionId: String(session?.id ?? ''), type: event?.type })
205
+ } catch (error) {
206
+ ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
207
+ }
208
+ }), 'dsh-kanban: события сессии')
209
+
210
+ // Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
211
+ // перекрытие запусков не допускается: медленный инстанс иначе копил бы
212
+ // очередь сверок вместо одной идущей.
213
+ // Очередь отправки в Gitea. Перенос карточки не ждёт сети: доска обязана
214
+ // работать и при недоступном Gitea, а неудачная отправка повторяется и, если
215
+ // так и не удалась, попадает в журнал задачи.
216
+ const outbox = createOutbox({ gitea, store, logger: ctx.logger })
217
+
218
+ const queueOutbound = (taskId, column) => {
219
+ if (getConfig().pushToGitea !== true) return
220
+ const task = store.getTask(taskId)
221
+ if (task === undefined) return
222
+ const ops = planOutbound(task, column, getConfig())
223
+ if (ops.length > 0) outbox.push(taskId, ops)
224
+ }
225
+
226
+ /**
227
+ * Владелец для коротких имён в списке отслеживаемых репозиториев.
228
+ *
229
+ * Берётся из задач, уже лежащих на доске: другого источника у плагина нет,
230
+ * а спрашивать владельца отдельной настройкой значит просить дважды одно и
231
+ * то же.
232
+ */
233
+ let discoveredOwner
234
+ const defaultOwner = async () => {
235
+ const named = String(getConfig().giteaOwner ?? '').trim()
236
+ if (named !== '') return named
237
+ for (const task of store.listWatchable()) {
238
+ if (typeof task.owner === 'string' && task.owner !== '') return task.owner
239
+ }
240
+ if (discoveredOwner !== undefined) return discoveredOwner
241
+ // Спрашиваем у Gitea, и только когда организация ровно одна: при
242
+ // нескольких гадание молча смотрело бы не туда, а это хуже отказа.
243
+ const orgs = await gitea.listOrgs()
244
+ if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
245
+ return undefined
246
+ }
247
+
248
+ // Состояние сверки видно человеку в шапке доски: доска, переставшая
249
+ // обновляться, не имеет права молчать об этом.
250
+ const syncState = createSyncState()
251
+
252
+ let syncing = false
253
+ const runSync = async () => {
254
+ if (syncing) return { skipped: true }
255
+ syncing = true
256
+ syncState.started()
257
+ try {
258
+ // Порядок важен. Сперва забираем новое: свежая задача должна попасть на
259
+ // доску и тут же быть сверена, а не ждать следующего прохода. Архивация
260
+ // последней — она смотрит на итог, а не на исходное.
261
+ const taken = await intakeAll({
262
+ gitea, store, config: getConfig(), owner: await defaultOwner(), logger: ctx.logger,
263
+ })
264
+ const out = await syncAll({ gitea, store, logger: ctx.logger })
265
+ const tidied = archiveOverdue({ store, config: getConfig() })
266
+ // Отложенные отправки уходят той же волной: отдельный таймер ради них
267
+ // означал бы второй источник обращений к Gitea без всякой пользы.
268
+ const pushed = await outbox.flush()
269
+ // Беда подхвата — такая же слепота доски, как беда сверки: задачи не
270
+ // приезжают, а человеку об этом никто не говорит.
271
+ syncState.finished(out.problem === undefined && taken.problem !== undefined
272
+ ? { ...out, problem: taken.problem }
273
+ : out)
274
+ return { ...out, pushed, taken, tidied }
275
+ } catch (error) {
276
+ // Сверка не начиналась вовсе — тоже беда, и своя: так выглядит
277
+ // неразрешённый токен или отсутствующий клиент Gitea.
278
+ syncState.failedToStart('сверка', error?.message)
279
+ throw error
280
+ } finally {
281
+ syncing = false
282
+ }
283
+ }
284
+
285
+ ctx.effect(() => {
286
+ let timer
287
+ const arm = () => {
288
+ const seconds = Number(getConfig().syncIntervalSec) || 0
289
+ clearInterval(timer)
290
+ timer = undefined
291
+ if (seconds <= 0) return
292
+ timer = setInterval(() => { runSync().catch(() => {}) }, Math.max(seconds, 15) * 1000)
293
+ if (typeof timer.unref === 'function') timer.unref()
294
+ }
295
+ arm()
296
+ rearmers.add(arm)
297
+ return () => { rearmers.delete(arm); clearInterval(timer) }
298
+ }, 'dsh-kanban: таймер сверки')
299
+
300
+ ctx.effect(() => ctx.webServer.register({
301
+ kind: 'exact',
302
+ path: '/dsh-kanban/project-task',
303
+ handler: async (req, res) => {
304
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
305
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
306
+ try {
307
+ const input = await readBody(req)
308
+ reply(res, await createProjectTask({
309
+ gitea, store,
310
+ owner: input.owner || await defaultOwner(),
311
+ repo: input.repo, newRepo: input.newRepo,
312
+ title: input.title, body: input.body,
313
+ board: input.board ?? 'main',
314
+ }))
315
+ } catch (error) {
316
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
317
+ }
318
+ },
319
+ }), 'dsh-kanban: проектная задача')
320
+
321
+ ctx.effect(() => ctx.webServer.register({
322
+ kind: 'exact',
323
+ path: '/dsh-kanban/presets',
324
+ // Обе службы необязательны: развёртывание может не собирать ни профили,
325
+ // ни уровни доступа. Пустой список — не поломка, а «выбирать не из чего»,
326
+ // и браузер просто не рисует поле.
327
+ handler: async (req, res) => {
328
+ const presets = ctx.get('agentPresets')
329
+ const permissions = ctx.get('permissionPresets')
330
+ let agentPresets = []
331
+ try {
332
+ const rows = presets === undefined ? [] : await presets.list()
333
+ // Сломанный профиль остаётся в списке ядра, но выбрать его нельзя:
334
+ // сессия на нём не соберётся, а отказ прилетит уже после запуска.
335
+ agentPresets = rows.filter((row) => row?.broken === undefined).map((row) => ({
336
+ id: row.id, name: row.name || row.id, description: row.description,
337
+ }))
338
+ } catch (error) {
339
+ ctx.logger?.warn?.(`dsh-kanban: профили агента не прочитались: ${error?.message}`)
340
+ }
341
+ let access = []
342
+ let accessDefault = ''
343
+ try {
344
+ if (permissions !== undefined) {
345
+ access = permissions.names.map((name) => permissions.optionOf(name))
346
+ accessDefault = permissions.defaultPreset ?? ''
347
+ }
348
+ } catch (error) {
349
+ ctx.logger?.warn?.(`dsh-kanban: уровни доступа не прочитались: ${error?.message}`)
350
+ }
351
+ reply(res, {
352
+ agentPresets,
353
+ agentPresetDefault: presets?.defaultId ?? '',
354
+ access,
355
+ accessDefault,
356
+ })
357
+ },
358
+ }), 'dsh-kanban: профили агента и уровни доступа')
359
+
360
+ ctx.effect(() => ctx.webServer.register({
361
+ kind: 'exact',
362
+ path: '/dsh-kanban/sessions',
363
+ handler: (req, res) => { reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) }) },
364
+ }), 'dsh-kanban: живые сессии')
365
+
366
+ ctx.effect(() => ctx.webServer.register({
367
+ kind: 'exact',
368
+ path: '/dsh-kanban/batch',
369
+ handler: async (req, res) => {
370
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
371
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
372
+ try {
373
+ const input = await readBody(req)
374
+ const ids = Array.isArray(input.ids) ? input.ids : []
375
+ const picked = ids.map((id) => store.getTask(id)).filter((task) => task !== undefined)
376
+ const chosen = resolveModel({
377
+ requested: input,
378
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
379
+ })
380
+ if (chosen === undefined) { writeJson(res, 400, { error: 'model-required' }); return }
381
+ reply(res, await runBatch({
382
+ agents: ctx.agents, store, tasks: picked, config: getConfig(),
383
+ provider: chosen.provider, model: chosen.model,
384
+ mintSessionId: () => SessionId(`kanban-batch-${randomUUID()}`),
385
+ createMessage: createUserMessage,
386
+ cwdOf: undefined, logger: ctx.logger, text: input.text,
387
+ agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
388
+ permission: typeof input.permission === 'string' ? input.permission : undefined,
389
+ permissions: ctx.get('permissionPresets'),
390
+ }))
391
+ } catch (error) {
392
+ writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
393
+ }
394
+ },
395
+ }), 'dsh-kanban: групповой запуск')
396
+
397
+ ctx.effect(() => ctx.webServer.register({
398
+ kind: 'exact',
399
+ path: '/dsh-kanban/archive',
400
+ handler: (req, res) => { reply(res, listArchive({ store })) },
401
+ }), 'dsh-kanban: архив')
402
+
403
+ ctx.effect(() => ctx.webServer.register({
404
+ kind: 'exact',
405
+ path: '/dsh-kanban/sync',
406
+ handler: async (req, res) => {
407
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
408
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
409
+ try {
410
+ writeJson(res, 200, await runSync())
411
+ } catch (error) {
412
+ writeJson(res, 502, { error: 'sync-failed' })
413
+ }
414
+ },
415
+ }), 'dsh-kanban: ручная сверка')
416
+
417
+ ctx.effect(() => ctx.webServer.register({
418
+ kind: 'exact',
419
+ path: '/dsh-kanban/webhook',
420
+ handler: async (req, res) => {
421
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
422
+ try {
423
+ const raw = await readRaw(req)
424
+ const secret = await resolveSecret(getConfig().webhookSecretRef)
425
+ // Без секрета вебхук не принимается вовсе: иначе карточки двигал бы
426
+ // кто угодно снаружи.
427
+ if (!verifySignature(secret, raw, req.headers['x-gitea-signature'])) {
428
+ writeJson(res, 401, { error: 'bad-signature' })
429
+ return
430
+ }
431
+ let payload
432
+ try { payload = JSON.parse(raw.toString('utf8')) } catch { payload = undefined }
433
+ const target = parseEvent(payload)
434
+ if (target === undefined) { writeJson(res, 202, { ignored: true }); return }
435
+
436
+ // Сперва подхват, потом сверка — тот же порядок, что и по таймеру:
437
+ // новый issue должен стать карточкой и тут же быть сверен. Без этого
438
+ // он ждал бы таймера, хотя вебхук про него уже знает, а вебхук мы и
439
+ // заводили ради двух секунд вместо ста двадцати.
440
+ //
441
+ // Только по репозиторию события: полный обход при каждом чихе в любом
442
+ // из отслеживаемых означал бы обращение ко всем.
443
+ // Событие всегда называет свой репозиторий, и владельца выяснять не
444
+ // надо: подхватываем его напрямую, если он вообще под наблюдением.
445
+ const owner = await defaultOwner()
446
+ const taken = target.owner === owner || isWatched({
447
+ config: getConfig(), owner: target.owner, defaultOwner: owner, repo: target.repo,
448
+ })
449
+ ? await intakeAll({
450
+ gitea, store, config: getConfig(), owner: await defaultOwner(), logger: ctx.logger,
451
+ only: (pair) => pair.owner === target.owner && pair.repo === target.repo,
452
+ })
453
+ : { added: 0, skipped: 0, failed: 0 }
454
+
455
+ // Вебхук говорит «здесь что-то произошло», а решает сверка: у неё перед
456
+ // глазами полная картина, а у события — только его кусок.
457
+ const out = await syncAll({
458
+ gitea, store, logger: ctx.logger,
459
+ only: (t) => t.owner === target.owner && t.repo === target.repo
460
+ && (target.issueNumber === undefined || t.issueNumber === target.issueNumber),
461
+ })
462
+ writeJson(res, 200, { ...out, taken })
463
+ } catch (error) {
464
+ writeJson(res, error?.status ?? 500, { error: 'webhook-failed' })
465
+ }
466
+ },
467
+ }), 'dsh-kanban: вебхук Gitea')
468
+
469
+ // Инструмент для агента. По умолчанию ВЫКЛЮЧЕН: скилл воркфлоу пока
470
+ // запрещает этому CLI трогать канбан, и включить инструмент раньше правки
471
+ // скилла — значит толкать агента на нарушение.
472
+ ctx.effect(() => {
473
+ let unregister
474
+ const arm = () => {
475
+ const wanted = getConfig().boardToolEnabled === true
476
+ if (wanted === (unregister !== undefined)) return
477
+ if (!wanted) { unregister?.(); unregister = undefined; return }
478
+ unregister = registerBoardTool()
479
+ }
480
+
481
+ // Один выключатель на оба инструмента доски: разделять их значило бы
482
+ // множить настройки ради разницы, которой человек не почувствует.
483
+ const registerBoardTool = () => {
484
+ const off = [
485
+ ctx.tools.register(defineTool(boardMoveDefinition({ store }))),
486
+ ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
487
+ ]
488
+ return () => { for (const stop of off) stop?.() }
489
+ }
490
+
491
+ arm()
492
+ rearmers.add(arm)
493
+ return () => { rearmers.delete(arm); unregister?.() }
494
+ }, 'dsh-kanban: инструмент доски')
495
+
496
+ /**
497
+ * Состояние живого агента сессии либо `undefined`, если агента нет.
498
+ * Отказ реестра на незнакомый идентификатор ловит сборка доски.
499
+ */
500
+ const liveOf = (sessionId) => ctx.agents.get(sessionId)?.status
501
+
502
+ ctx.effect(() => ctx.webServer.register({
503
+ kind: 'exact',
504
+ path: '/dsh-kanban/board',
505
+ handler: (req, res) => {
506
+ const url = urlOf(req)
507
+ reply(res, buildBoard({
508
+ store,
509
+ config: getConfig(),
510
+ board: url.searchParams.get('board') || 'main',
511
+ repo: url.searchParams.get('repo') || undefined,
512
+ liveOf,
513
+ sync: syncState.snapshot(),
514
+ // Фактический корень проектов едет с доской: браузер показывает его в
515
+ // окне запуска, когда человек не задал корень настройкой, — иначе
516
+ // сессия молча поднимается в рабочей папке харнесса.
517
+ projectRoot: rootOf(getConfig()),
518
+ }))
519
+ },
520
+ }), 'dsh-kanban: маршрут доски')
521
+
522
+ ctx.effect(() => ctx.webServer.register({
523
+ // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
524
+ // сводится к равенству пути, и всё, что глубже, уходит в статику.
525
+ kind: 'prefix',
526
+ path: '/dsh-kanban/task',
527
+ handler: async (req, res) => {
528
+ const pathname = urlOf(req).pathname
529
+
530
+ // Сам путь без идентификатора — создание своей задачи.
531
+ if (pathname === '/dsh-kanban/task' || pathname === '/dsh-kanban/task/') {
532
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
533
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
534
+ try {
535
+ reply(res, createTask({ store, input: await readBody(req) }))
536
+ } catch (error) {
537
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
538
+ }
539
+ return
540
+ }
541
+
542
+ const parsed = parseTaskPath(pathname)
543
+ if (parsed === undefined) { writeJson(res, 404, { error: 'not-found' }); return }
544
+ const { id, action } = parsed
545
+
546
+ if (req.method === 'GET' && action === 'log') { reply(res, taskLog({ store, id })); return }
547
+ if (req.method === 'GET' && action === 'body') { reply(res, taskBody({ store, id })); return }
548
+
549
+ // Заготовку сообщения отдаём отсюда, а не собираем второй раз в браузере:
550
+ // иначе шаблон окажется в двух местах и однажды разойдётся.
551
+ if (req.method === 'GET' && action === 'message') {
552
+ const task = store.getTask(id)
553
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
554
+ writeJson(res, 200, { text: buildStartMessage(task, getConfig()) })
555
+ return
556
+ }
557
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
558
+ if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
559
+ if (req.method === 'POST' && action === 'refresh') {
560
+ reply(res, await refreshTask({ gitea, store, id }))
561
+ return
562
+ }
563
+ if (req.method === 'POST' && action === 'start') {
564
+ try {
565
+ const input = await readBody(req)
566
+ const task = store.getTask(id)
567
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
568
+ const picked = resolveModel({
569
+ requested: input,
570
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
571
+ })
572
+ if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
573
+ reply(res, await runTask({
574
+ agents: ctx.agents,
575
+ store,
576
+ task,
577
+ config: getConfig(),
578
+ provider: picked.provider,
579
+ model: picked.model,
580
+ // Идентификатор мнётся ЛЕНИВО: при продолжении новая сессия не
581
+ // поднимается вовсе, и заранее занятое имя осталось бы висеть.
582
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
583
+ createMessage: createUserMessage,
584
+ logger: ctx.logger,
585
+ text: typeof input.text === 'string' ? input.text : undefined,
586
+ agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
587
+ permission: typeof input.permission === 'string' ? input.permission : undefined,
588
+ permissions: ctx.get('permissionPresets'),
589
+ }))
590
+ } catch (error) {
591
+ ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
592
+ // Отказ по уровню доступа называется своим именем: «не удалось» на
593
+ // месте «сессия пошла бы с большими правами» — это молчание.
594
+ writeJson(res, error?.status ?? 500, { error: error?.key ?? 'start-failed' })
595
+ }
596
+ return
597
+ }
598
+
599
+ try {
600
+ const input = await readBody(req)
601
+ if (req.method === 'POST' && action === 'move') {
602
+ // Ручной перенос — КОМАНДА агенту, а не запись о состоянии.
603
+ // Автоматика (сверка, инструмент, жизненный цикл) сюда не приходит:
604
+ // она двигает карточку мимо этого маршрута.
605
+ //
606
+ // Команду отдаём ДО переноса: остановка должна случиться раньше, чем
607
+ // доска объявит работу прекращённой, а её исход становится
608
+ // пояснением к единственному переходу в журнале.
609
+ const was = store.getTask(id)
610
+ const acted = was !== undefined && input.column !== was.column
611
+ ? dispatchMove({
612
+ agents: ctx.agents, task: was, column: input.column,
613
+ kind: store.boardKind(was.board),
614
+ createMessage: createUserMessage, logger: ctx.logger,
615
+ }).acted
616
+ : undefined
617
+ const out = applyMove({
618
+ store, config: getConfig(), id,
619
+ column: input.column, beforeId: input.beforeId, afterId: input.afterId,
620
+ detail: acted === undefined ? '' : (MOVE_DETAIL[acted] ?? ''),
621
+ })
622
+ if (out?.task !== undefined) {
623
+ queueOutbound(id, out.task.column)
624
+ if (acted !== undefined) out.command = acted
625
+ }
626
+ reply(res, out)
627
+ return
628
+ }
629
+ if (req.method === 'POST' && action === 'stop') {
630
+ const task = store.getTask(id)
631
+ if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
632
+ const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
633
+ if (out.acted === 'stopped') {
634
+ // Остановка — событие, и в журнале ей место наравне с переносом:
635
+ // иначе прерванная работа выглядит просто оборвавшейся сама.
636
+ store.addTransition({
637
+ taskId: task.id, fromCol: task.column, toCol: task.column,
638
+ source: 'manual', detail: STOP_DETAIL.stopped,
639
+ })
640
+ }
641
+ reply(res, out)
642
+ return
643
+ }
644
+ if (req.method === 'POST' && action === 'archive') {
645
+ reply(res, setArchived({ store, id, archived: true }))
646
+ return
647
+ }
648
+ if (req.method === 'POST' && action === 'restore') {
649
+ reply(res, setArchived({ store, id, archived: false }))
650
+ return
651
+ }
652
+ if (req.method === 'POST' && action === 'queue') {
653
+ reply(res, await queueTask({
654
+ agents: ctx.agents, store, task: store.getTask(id),
655
+ sessionId: input.sessionId, config: getConfig(),
656
+ createMessage: createUserMessage, text: input.text,
657
+ }))
658
+ return
659
+ }
660
+ if (req.method === 'POST' && action === 'unqueue') {
661
+ reply(res, unqueueTask({ store, task: store.getTask(id) }))
662
+ return
663
+ }
664
+ if (req.method === 'POST' && action === 'note') {
665
+ reply(res, appendNote({ store, id, input }))
666
+ return
667
+ }
668
+ if (req.method === 'PATCH' && action === undefined) {
669
+ reply(res, updateTask({ store, id, input }))
670
+ return
671
+ }
672
+ writeJson(res, 405, { error: 'method-not-allowed' })
673
+ } catch (error) {
674
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
675
+ }
676
+ },
677
+ }), 'dsh-kanban: маршруты задачи')
678
+
679
+ // Готовность Gitea проверяется на каждом запросе, а не при загрузке: адрес и
680
+ // учётная запись правятся в карточке настроек на ходу, и маршруты обязаны
681
+ // внятно объяснять причину, а не отвечать 404.
682
+ ctx.effect(() => ctx.webServer.register({
683
+ kind: 'exact',
684
+ path: '/dsh-kanban/gitea/repos',
685
+ handler: async (req, res) => {
686
+ const url = urlOf(req)
687
+ reply(res, await searchRepos({ gitea, query: url.searchParams.get('q') || '' }))
688
+ },
689
+ }), 'dsh-kanban: поиск репозиториев')
690
+
691
+ ctx.effect(() => ctx.webServer.register({
692
+ kind: 'exact',
693
+ path: '/dsh-kanban/gitea/issues',
694
+ handler: async (req, res) => {
695
+ const url = urlOf(req)
696
+ reply(res, await listImportable({
697
+ gitea,
698
+ store,
699
+ owner: url.searchParams.get('owner') || undefined,
700
+ repo: url.searchParams.get('repo') || undefined,
701
+ board: url.searchParams.get('board') || 'main',
702
+ }))
703
+ },
704
+ }), 'dsh-kanban: список issue для импорта')
705
+
706
+ ctx.effect(() => ctx.webServer.register({
707
+ kind: 'exact',
708
+ path: '/dsh-kanban/import',
709
+ handler: async (req, res) => {
710
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
711
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
712
+ try {
713
+ const input = await readBody(req)
714
+ reply(res, await importIssue({
715
+ gitea, store,
716
+ owner: input.owner, repo: input.repo, issueNumber: input.issueNumber,
717
+ board: input.board ?? 'main', column: input.column ?? 'backlog',
718
+ }))
719
+ } catch (error) {
720
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
721
+ }
722
+ },
723
+ }), 'dsh-kanban: импорт issue')
724
+
725
+ // Провайдеры отдаются сразу, модели — только для запрошенного провайдера:
726
+ // перечень моделей провайдер отдаёт по сети, и опрашивать все разом ради
727
+ // одного выпадающего списка незачем.
728
+ ctx.effect(() => ctx.webServer.register({
729
+ kind: 'exact',
730
+ path: '/dsh-kanban/models',
731
+ handler: async (req, res) => {
732
+ const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
733
+ let providers = []
734
+ try {
735
+ providers = (ctx.llm.listProviders() ?? []).map((p) => ({ id: p.id, name: p.name ?? p.id }))
736
+ } catch { providers = [] }
737
+
738
+ const wanted = urlOf(req).searchParams.get('provider') || ''
739
+ if (wanted === '') { writeJson(res, 200, { current, providers, models: [] }); return }
740
+
741
+ try {
742
+ const rows = await ctx.llm.listModels(wanted)
743
+ writeJson(res, 200, {
744
+ current, providers,
745
+ models: (rows ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id })),
746
+ })
747
+ } catch {
748
+ // Провайдер может быть не настроен или недоступен: это не поломка
749
+ // доски, а пустой список с сохранённым выбором провайдера.
750
+ writeJson(res, 200, { current, providers, models: [] })
751
+ }
752
+ },
753
+ }), 'dsh-kanban: провайдеры и модели')
754
+
755
+ ctx.effect(() => ctx.webServer.register({
756
+ kind: 'prefix',
757
+ path: '/dsh-kanban/session',
758
+ handler: (req, res) => {
759
+ const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
760
+ if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
761
+ reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]) }))
762
+ },
763
+ }), 'dsh-kanban: маршрут задачи по сессии')
764
+ }