@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/LICENSE +21 -0
- package/README.md +455 -0
- package/cordis.patch.yml +4 -0
- package/lib/board-tool.js +178 -0
- package/lib/client.js +3197 -0
- package/lib/commands.js +167 -0
- package/lib/config.js +170 -0
- package/lib/filters.js +243 -0
- package/lib/gitea.js +309 -0
- package/lib/import.js +214 -0
- package/lib/index.js +764 -0
- package/lib/intake.js +94 -0
- package/lib/launcher.js +432 -0
- package/lib/lifecycle.js +32 -0
- package/lib/markdown.js +161 -0
- package/lib/order.js +66 -0
- package/lib/outbound.js +88 -0
- package/lib/plan.js +153 -0
- package/lib/routes.js +256 -0
- package/lib/store.js +366 -0
- package/lib/sync.js +369 -0
- package/lib/time.js +45 -0
- package/lib/transitions.js +162 -0
- package/lib/webhook.js +54 -0
- package/package.json +89 -0
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
|
+
}
|