@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/intake.js ADDED
@@ -0,0 +1,94 @@
1
+ // Архив выполненного и подхват новых задач из Gitea.
2
+ //
3
+ // Две противоположные заботы в одном месте: одна убирает с доски отработавшее,
4
+ // другая приносит новое. Обе решают, ЧТО должно быть на доске, и держать их
5
+ // рядом дешевле, чем искать по разным углам, почему карточка появилась или
6
+ // пропала.
7
+
8
+ /** Разбор списка репозиториев из настройки: через запятую, пробелы не в счёт. */
9
+ export function parseWatchList(value) {
10
+ return String(value ?? '')
11
+ .split(',')
12
+ .map((name) => name.trim())
13
+ .filter((name) => name !== '')
14
+ }
15
+
16
+ /**
17
+ * Репозитории, за которыми следим, если их назвали поимённо.
18
+ *
19
+ * Пустой список означает «все», и разбирается это не здесь, а в проходе
20
+ * подхвата: там есть Gitea, у которого можно спросить, какие репозитории вообще
21
+ * существуют.
22
+ *
23
+ * @returns {Array<{owner: string, repo: string}>}
24
+ */
25
+ export function watchedRepos({ config, owner }) {
26
+ const out = []
27
+ const seen = new Set()
28
+ for (const name of parseWatchList(config?.watchRepos)) {
29
+ // Имя допускается и полное, и короткое: `владелец/репо` либо просто
30
+ // `репо` — тогда владелец берётся общий.
31
+ const at = name.indexOf('/')
32
+ const pair = at > 0
33
+ ? { owner: name.slice(0, at), repo: name.slice(at + 1) }
34
+ : { owner, repo: name }
35
+ if (pair.owner === undefined || pair.owner === '' || pair.repo === '') continue
36
+ const key = `${pair.owner}/${pair.repo}`
37
+ if (seen.has(key)) continue
38
+ seen.add(key)
39
+ out.push(pair)
40
+ }
41
+ return out
42
+ }
43
+
44
+ /**
45
+ * Стоит ли заводить карточку под этот issue.
46
+ *
47
+ * @returns {{take: boolean, why: string}} `why` называет причину отказа —
48
+ * иначе «почему этой задачи нет на доске» превращается в гадание.
49
+ */
50
+ export function shouldTake({ store, owner, repo, issue }) {
51
+ const number = issue?.number
52
+ if (typeof number !== 'number') return { take: false, why: 'без номера' }
53
+ // Pull request в Gitea — тоже issue, но работой на доске он не является:
54
+ // он ход работы, а не сама работа.
55
+ if (issue?.pull_request !== undefined && issue?.pull_request !== null) {
56
+ return { take: false, why: 'это pull request' }
57
+ }
58
+ if (issue?.state === 'closed') return { take: false, why: 'issue закрыт' }
59
+ if (store.findTaskByIssue({ owner, repo, issueNumber: number }) !== undefined) {
60
+ return { take: false, why: 'карточка уже есть' }
61
+ }
62
+ if (store.isDismissed({ owner, repo, issueNumber: number })) {
63
+ return { take: false, why: 'карточку удалили — значит видеть её здесь не хотят' }
64
+ }
65
+ return { take: true, why: '' }
66
+ }
67
+
68
+ /**
69
+ * Пора ли задаче в архив.
70
+ *
71
+ * Архивируем ТОЛЬКО из `done`. Задача, застрявшая в ревью на месяц, — это
72
+ * сигнал, а не мусор, и прятать её нельзя.
73
+ *
74
+ * Ноль в настройке отключает самоархивацию: это осознанное «не убирать»,
75
+ * а не пропущенное значение.
76
+ */
77
+ export function archiveBefore({ now, afterDays }) {
78
+ const days = Number(afterDays)
79
+ if (!Number.isFinite(days) || days <= 0) return undefined
80
+ return now - days * 24 * 60 * 60 * 1000
81
+ }
82
+
83
+ /**
84
+ * Следим ли за этим репозиторием.
85
+ *
86
+ * Событие из репозитория вне списка подхвата не заслуживает: по таймеру мы
87
+ * туда не ходим, и через вебхук ходить тоже не должны — иначе список перестаёт
88
+ * что-либо решать.
89
+ */
90
+ export function isWatched({ config, owner, defaultOwner, repo }) {
91
+ return watchedRepos({ config, owner: defaultOwner }).some(
92
+ (pair) => pair.owner === owner && pair.repo === repo,
93
+ )
94
+ }
@@ -0,0 +1,432 @@
1
+ // Запуск работы по задаче: отдельная сессия агента с выбранной моделью.
2
+ //
3
+ // Плагин НЕ готовит ничего, кроме сессии. Ветку и worktree он не создаёт: по
4
+ // воркфлоу агент сначала делает preflight — смотрит открытые ветки и worktree,
5
+ // проверяет рабочее дерево — и только потом заводит worktree от свежего
6
+ // origin/main. Плагин, создающий ветку заранее, ломает preflight: ветка
7
+ // появится до того, как выяснится, что задачу вообще нельзя начинать.
8
+ //
9
+ // По той же причине плагин не пишет стартовый комментарий в issue. Его пишет
10
+ // агент после preflight, потому что до preflight ветка и план неизвестны.
11
+ //
12
+ // Пакеты ядра здесь не импортируются: идентификатор сессии и сборка сообщения
13
+ // приходят параметрами. Так модуль проверяется без харнесса и без сети.
14
+
15
+ import { isAbsolute, join, resolve } from 'node:path'
16
+
17
+ /**
18
+ * Хвост своей задачи — УСЛОВИЕ, а не приказ.
19
+ *
20
+ * На доску вешают что угодно: «прибраться в логах», «найди мне вот такую
21
+ * информацию». Распоряжение делать preflight веток и заводить worktree для
22
+ * такой задачи бессмысленно — агент честно уходил искать репозитории и
23
+ * перечислять worktree вместо того, чтобы заняться написанным.
24
+ *
25
+ * Поэтому решает агент по содержанию задачи, а не доска заранее.
26
+ */
27
+ const OWN_TAIL = 'Если задача окажется про код проекта — придерживайся его воркфлоу; '
28
+ + 'если нет — просто сделай, что написано выше.'
29
+
30
+ /** Хвост про воркфлоу — ТОЛЬКО для задач, за которыми стоит issue. */
31
+ const WORKFLOW_TAIL = [
32
+ 'Работай по действующему воркфлоу проекта: сначала preflight открытых веток и',
33
+ 'worktree, затем worktree от свежего origin/main, стартовый комментарий в issue',
34
+ 'с планом и проверками, дальше реализация.',
35
+ ].join('\n')
36
+
37
+ /**
38
+ * Первое сообщение агенту.
39
+ *
40
+ * Сообщение НЕ содержит указаний создать ветку с конкретным именем: имя ветки
41
+ * выбирает агент после preflight.
42
+ *
43
+ * @param {object} task задача доски
44
+ * @param {object} config настройки плагина
45
+ * @returns {string} текст сообщения
46
+ */
47
+ export function buildStartMessage(task, config) {
48
+ const vars = {
49
+ repo: task?.repo ?? '',
50
+ issueNumber: typeof task?.issueNumber === 'number' ? String(task.issueNumber) : '',
51
+ title: task?.title ?? '',
52
+ body: task?.body ?? '',
53
+ issueUrl: task?.issueUrl ?? '',
54
+ }
55
+
56
+ const note = typeof config?.replyInstruction === 'string' ? config.replyInstruction.trim() : ''
57
+
58
+ const custom = typeof config?.startPrompt === 'string' ? config.startPrompt.trim() : ''
59
+ if (custom !== '') return joinBlocks([substitute(custom, vars), note])
60
+
61
+ // Задача из issue и своя задача — разные сообщения, а не одно с пропусками.
62
+ const fromIssue = vars.repo !== '' && vars.issueNumber !== ''
63
+ const head = fromIssue
64
+ ? `Задача ${vars.repo}#${vars.issueNumber}: ${vars.title}`
65
+ : `Задача: ${vars.title}`
66
+
67
+ return joinBlocks([
68
+ head,
69
+ vars.body,
70
+ fromIssue && vars.issueUrl !== '' ? `Ссылка: ${vars.issueUrl}` : '',
71
+ fromIssue ? WORKFLOW_TAIL : OWN_TAIL,
72
+ note,
73
+ ])
74
+ }
75
+
76
+ /** Склеить непустые куски пустой строкой между ними. */
77
+ function joinBlocks(parts) {
78
+ return parts.map((p) => String(p ?? '').trim()).filter(Boolean).join('\n\n')
79
+ }
80
+
81
+ function substitute(template, vars) {
82
+ let text = template
83
+ for (const [name, value] of Object.entries(vars)) {
84
+ text = text.split(`{${name}}`).join(value)
85
+ }
86
+ return text
87
+ }
88
+
89
+ /**
90
+ * Рабочая папка сессии — КОРНЕВОЙ checkout проекта, не worktree.
91
+ *
92
+ * Корневой checkout по воркфлоу только для чтения, и это ровно то место, где
93
+ * положено начинать: preflight читает состояние репозитория. Worktree заведёт
94
+ * агент.
95
+ *
96
+ * Имя репозитория приходит из данных Gitea, а не из кода, поэтому выход за
97
+ * пределы корня отвергается: иначе задача с подделанным именем увела бы сессию
98
+ * в произвольный каталог.
99
+ *
100
+ * @throws если корень не абсолютный либо имя репозитория выводит за его пределы
101
+ */
102
+ export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
103
+ const root = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
104
+ ? config.defaultProjectRoot.trim()
105
+ : cwdOf()
106
+ if (!isAbsolute(root)) throw new Error('корень проектов должен быть абсолютным путём')
107
+
108
+ const repo = typeof task?.repo === 'string' ? task.repo.trim() : ''
109
+ if (repo === '') return root
110
+
111
+ const target = resolve(join(root, repo))
112
+ const fence = resolve(root)
113
+ if (target !== fence && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
114
+ throw new Error('имя репозитория выводит за пределы корня проектов')
115
+ }
116
+ return target
117
+ }
118
+
119
+ /**
120
+ * Взять агента задачи: живого, возобновлённого или нового.
121
+ *
122
+ * Правило владельца — **одна задача, один чат**, сколько бы этапов в ней ни
123
+ * было. Поэтому новую сессию поднимаем только когда прежней взять неоткуда.
124
+ *
125
+ * Возобновление не проверяем заранее отдельным вопросом «сохранена ли сессия»:
126
+ * такого вопроса у ядра нет, а `resume` сам скажет отказом. Пробуем и ловим.
127
+ *
128
+ * @returns {{agent: object, sessionId: string, mode: 'opened'|'resumed'|'created'}}
129
+ */
130
+ export function applyPermission({ permissions, agent, name }) {
131
+ if (typeof name !== 'string' || name === '') return { skipped: 'not-picked' }
132
+ // Служба необязательна: развёртывание может её не собирать. Но если человек
133
+ // выбрал уровень доступа, а поставить его нечем — запускать нельзя: сессия
134
+ // пошла бы с правами по умолчанию, то есть, возможно, БОЛЬШИМИ, чем просили.
135
+ if (permissions === undefined || typeof permissions.set !== 'function') {
136
+ return { error: 'permission-unavailable' }
137
+ }
138
+ try {
139
+ permissions.set(agent.session, name)
140
+ return { applied: name }
141
+ } catch (error) {
142
+ return { error: 'permission-refused', reason: error?.message }
143
+ }
144
+ }
145
+
146
+ export async function obtainAgent({
147
+ agents, task, config, provider, model, mintSessionId, cwdOf, logger,
148
+ agentPreset, permission, permissions,
149
+ }) {
150
+ const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
151
+
152
+ if (known !== undefined) {
153
+ // Живой агент — просто берём его.
154
+ let live
155
+ try { live = agents.get(known) } catch { live = undefined }
156
+ if (live !== undefined) return { agent: live, sessionId: known, mode: 'opened' }
157
+
158
+ // Сессия выгружена, но могла сохраниться.
159
+ try {
160
+ const handle = await agents.resume({ resumeSessionId: known, agentOptions: { provider, model } })
161
+ return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'resumed' }
162
+ } catch (error) {
163
+ logger?.warn?.(`dsh-kanban: сессия ${known} не возобновилась: ${error?.message}`)
164
+ }
165
+ }
166
+
167
+ const cwd = resolveCwd(task, config, cwdOf)
168
+ const handle = await agents.create({
169
+ sessionId: mintSessionId(),
170
+ // Профиль — факт создания сессии: у идущей его не сменить, поэтому он
171
+ // едет здесь, а не отдельным вызовом после.
172
+ meta: typeof agentPreset === 'string' && agentPreset !== '' ? { cwd, agentPreset } : { cwd },
173
+ agentOptions: { provider, model },
174
+ })
175
+ const access = applyPermission({ permissions, agent: handle.agent, name: permission })
176
+ if (access.error !== undefined) {
177
+ // Сессия уже поднята, но работать в ней с чужими правами не дадим.
178
+ try { handle.dispose?.() } catch { /* уборка не важнее отказа */ }
179
+ logger?.warn?.(`dsh-kanban: уровень доступа не поставлен: ${access.reason ?? access.error}`)
180
+ const error = new Error('уровень доступа не поставлен')
181
+ error.key = access.error
182
+ error.status = 409
183
+ throw error
184
+ }
185
+ return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'created' }
186
+ }
187
+
188
+ /**
189
+ * Запустить или продолжить работу по задаче.
190
+ *
191
+ * Порядок важен: сначала берём агента, дожидаемся простоя, отправляем
192
+ * сообщение и только потом пишем в хранилище. Запись до запуска оставила бы
193
+ * задачу в «В работе» без сессии, если запуск упадёт.
194
+ *
195
+ * Колонку двигаем ТОЛЬКО при первом запуске из бэклога. Задача в «Ревью», по
196
+ * которой нажали «Продолжить», обязана остаться в «Ревью»: продолжение — это
197
+ * не откат к началу.
198
+ *
199
+ * @param {string} [text] сообщение человека; пусто — встроенная заготовка
200
+ * @returns {{sessionId: string, mode: string, task: object}}
201
+ */
202
+ export async function runTask({
203
+ agents, store, task, config, provider, model,
204
+ mintSessionId, createMessage, cwdOf, logger, text,
205
+ agentPreset, permission, permissions,
206
+ }) {
207
+ const taken = await obtainAgent({
208
+ agents, task, config, provider, model, mintSessionId, cwdOf, logger,
209
+ agentPreset, permission, permissions,
210
+ })
211
+
212
+ await taken.agent.whenIdle()
213
+ taken.agent.followup(createMessage({
214
+ // Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
215
+ // (`content.some(...)`), и строка роняет ход целиком.
216
+ content: [{ type: 'text', text: messageFor(task, config, text) }],
217
+ source: { kind: 'plugin', plugin: 'dsh-kanban', form: 'task-start' },
218
+ }))
219
+
220
+ const before = store.getTask(task.id)
221
+ const patch = { sessionId: taken.sessionId, provider, model }
222
+ store.updateTask(task.id, patch)
223
+
224
+ // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
225
+ // поймёт, куда делась переписка.
226
+ if (taken.mode === 'created' && before.sessionId) {
227
+ store.addTransition({
228
+ taskId: task.id,
229
+ fromCol: before.column,
230
+ toCol: before.column,
231
+ source: 'session',
232
+ detail: `прежняя сессия не возобновилась, поднята новая: ${taken.sessionId}`,
233
+ })
234
+ }
235
+
236
+ let current = store.getTask(task.id)
237
+ if (taken.mode === 'created' && before.column === 'backlog') {
238
+ current = store.moveTask(task.id, { column: 'in-progress' })
239
+ store.addTransition({
240
+ taskId: task.id,
241
+ fromCol: before.column,
242
+ toCol: 'in-progress',
243
+ source: 'session',
244
+ detail: `сессия ${taken.sessionId}, модель ${model}`,
245
+ })
246
+ }
247
+
248
+ return { sessionId: taken.sessionId, mode: taken.mode, task: current }
249
+ }
250
+
251
+ /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
252
+ function messageFor(task, config, text) {
253
+ const own = typeof text === 'string' ? text.trim() : ''
254
+ return own !== '' ? own : buildStartMessage(task, config)
255
+ }
256
+
257
+ /**
258
+ * Провайдер и модель для запуска: сначала выбор человека, затем модель по
259
+ * умолчанию из харнесса. Если не выбрано ни там, ни там — внятный отказ, а не
260
+ * падение.
261
+ */
262
+ export function resolveModel({ requested, fallback }) {
263
+ const provider = requested?.provider || fallback?.provider
264
+ const model = requested?.model || fallback?.model
265
+ if (!provider || !model) return { error: 'model-not-selected', status: 409 }
266
+ return { provider, model }
267
+ }
268
+
269
+ /**
270
+ * Сообщение об одной задаче пачки.
271
+ *
272
+ * Номер и общее число — не украшение. По воркфлоу агент после задачи делает
273
+ * cleanup: удаляет ветку, worktree, закрывает issue. Получив первую из пяти и
274
+ * не зная про остальные, он честно объявит работу законченной и приберёт за
275
+ * собой — а следующая придёт в уже прибранный чат.
276
+ *
277
+ * Номер превращает пять отдельных работ в одну из пяти частей.
278
+ */
279
+ export function buildQueuedMessage(task, config, at, total) {
280
+ const body = buildStartMessage(task, config)
281
+ return total > 1 ? joinBlocks([`Задача ${at} из ${total}.`, body]) : body
282
+ }
283
+
284
+ /**
285
+ * Запустить пачку задач ОДНОЙ сессией, отдавая их по очереди.
286
+ *
287
+ * «Разом» не бывает: пачка всегда идёт по одной. Решение владельца, и правило
288
+ * лучше выбора — одним переключателем меньше и одним путём в коде меньше.
289
+ *
290
+ * В работу уезжает ТОЛЬКО первая. Остальные остаются на своих местах с
291
+ * отметкой очереди: десять карточек в «В работе», из которых делается одна, —
292
+ * это ложь, и предел колонки от неё теряет смысл.
293
+ *
294
+ * Порядок важен: сперва агент и сообщения, потом хранилище. Запись до запуска
295
+ * оставила бы карточки с идентификатором сессии, которой нет.
296
+ */
297
+ export async function runBatch({
298
+ agents, store, tasks, config, provider, model,
299
+ mintSessionId, createMessage, cwdOf, logger, text, now = Date.now(),
300
+ agentPreset, permission, permissions,
301
+ }) {
302
+ const list = (tasks ?? []).filter((task) => task !== undefined)
303
+ if (list.length === 0) return { error: 'nothing-picked', status: 400 }
304
+
305
+ const taken = await obtainAgent({
306
+ agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
307
+ agentPreset, permission, permissions,
308
+ })
309
+
310
+ await taken.agent.whenIdle()
311
+ const own = typeof text === 'string' ? text.trim() : ''
312
+ const send = (body, form) => taken.agent.followup(createMessage({
313
+ content: [{ type: 'text', text: body }],
314
+ source: { kind: 'plugin', plugin: 'dsh-kanban', form },
315
+ }))
316
+
317
+ if (own !== '' && list.length === 1) {
318
+ send(own, 'task-start')
319
+ } else {
320
+ list.forEach((task, at) => send(buildQueuedMessage(task, config, at + 1, list.length), 'batch-queued'))
321
+ }
322
+
323
+ const started = []
324
+ list.forEach((task, at) => {
325
+ const before = store.getTask(task.id)
326
+ if (before === undefined) return
327
+ // Первая — в работу; остальные ждут очереди там, где лежат. Отметка
328
+ // постановки идёт с шагом, чтобы порядок очереди совпал с порядком выбора
329
+ // даже когда всё случилось в одну миллисекунду.
330
+ const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
331
+ store.updateTask(task.id, patch)
332
+ if (at === 0 && before.column === 'backlog') {
333
+ store.moveTask(task.id, { column: 'in-progress' })
334
+ store.addTransition({
335
+ taskId: task.id, fromCol: before.column, toCol: 'in-progress', source: 'session',
336
+ detail: list.length > 1
337
+ ? `сессия ${taken.sessionId} на ${list.length} задач, модель ${model}`
338
+ : `сессия ${taken.sessionId}, модель ${model}`,
339
+ })
340
+ } else if (at > 0) {
341
+ store.addTransition({
342
+ taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
343
+ detail: `в очереди сессии ${taken.sessionId}, ${at + 1} из ${list.length}`,
344
+ })
345
+ }
346
+ started.push(task.id)
347
+ })
348
+
349
+ return { sessionId: taken.sessionId, mode: taken.mode, started: started.length, tasks: started }
350
+ }
351
+
352
+ /**
353
+ * Поставить задачу в очередь уже идущей сессии.
354
+ *
355
+ * Своей очереди не заводим: у ядра она есть. `followup` кладёт сообщение, а
356
+ * агент берёт его на ближайшей границе шага — то есть закончив текущее.
357
+ * Вторая очередь рядом с настоящей разошлась бы с ней при первой же заминке.
358
+ */
359
+ export async function queueTask({
360
+ agents, store, task, sessionId, config, createMessage, text, now = Date.now(),
361
+ }) {
362
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
363
+ const id = typeof sessionId === 'string' ? sessionId.trim() : ''
364
+ if (id === '') return { error: 'session-required', status: 400 }
365
+
366
+ let agent
367
+ try { agent = agents.get(id) } catch { agent = undefined }
368
+ // Мёртвой сессии ставить в очередь нечего: сообщение исчезнет, а карточка
369
+ // останется привязанной к тому, чего нет.
370
+ if (agent === undefined) return { error: 'session-not-live', status: 409 }
371
+
372
+ const own = typeof text === 'string' ? text.trim() : ''
373
+ agent.followup(createMessage({
374
+ content: [{ type: 'text', text: own !== '' ? own : buildStartMessage(task, config) }],
375
+ source: { kind: 'plugin', plugin: 'dsh-kanban', form: 'task-queued' },
376
+ }))
377
+
378
+ const before = store.getTask(task.id)
379
+ store.updateTask(task.id, { sessionId: id, queuedAt: now })
380
+ store.addTransition({
381
+ taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
382
+ detail: `поставлена в очередь сессии ${id}`,
383
+ })
384
+ return { task: store.getTask(task.id), sessionId: id }
385
+ }
386
+
387
+ /**
388
+ * Снять задачу с очереди.
389
+ *
390
+ * Обязательная пара к постановке: если поставить можно, а передумать нельзя,
391
+ * человек перестанет пользоваться и постановкой.
392
+ *
393
+ * Сообщение из очереди ядра не выдернуть — оно уже отдано. Поэтому снимаем
394
+ * привязку и честно говорим об этом в журнале: агент может дойти до задачи и
395
+ * увидеть карточку, которая его больше не ждёт.
396
+ */
397
+ export function unqueueTask({ store, task }) {
398
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
399
+ if (typeof task.queuedAt !== 'number' || task.queuedAt <= 0) {
400
+ return { error: 'task-not-queued', status: 400 }
401
+ }
402
+ store.updateTask(task.id, { queuedAt: 0, sessionId: null })
403
+ store.addTransition({
404
+ taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
405
+ detail: 'снята с очереди; сообщение агенту уже отдано и отозвать его нечем',
406
+ })
407
+ return { task: store.getTask(task.id) }
408
+ }
409
+
410
+ /**
411
+ * Живые сессии доски: куда вообще можно поставить задачу.
412
+ *
413
+ * Сессия без живого агента в список не попадает — предлагать её значило бы
414
+ * предлагать очередь, из которой никто ничего не возьмёт.
415
+ */
416
+ export function liveSessions({ store, agents }) {
417
+ const byId = new Map()
418
+ const all = [...store.listTasks({ board: 'main' }), ...store.listTasks({ board: 'simple' })]
419
+ for (const task of all) {
420
+ const id = typeof task.sessionId === 'string' ? task.sessionId : ''
421
+ if (id === '') continue
422
+ let live
423
+ try { live = agents.get(id) } catch { live = undefined }
424
+ if (live === undefined) continue
425
+ if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
426
+ byId.get(id).tasks.push({
427
+ id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
428
+ queued: typeof task.queuedAt === 'number' && task.queuedAt > 0,
429
+ })
430
+ }
431
+ return [...byId.values()]
432
+ }
@@ -0,0 +1,32 @@
1
+ // Признак ожидания по событиям сессии.
2
+ //
3
+ // Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения
4
+ // или вопрос агента НЕ двигают карточку, а поднимают на ней признак «нужен
5
+ // человек». Двигать в несуществующую колонку нельзя, а прятать «агент стоит и
6
+ // ждёт» — значит терять главное, ради чего смотрят на доску.
7
+ //
8
+ // Подписка на события ядра живёт в `lib/index.js`; здесь только решение, что
9
+ // делать с очередным событием.
10
+
11
+ import { waitingFromEvent } from './transitions.js'
12
+
13
+ /**
14
+ * Обработать событие сессии.
15
+ *
16
+ * Задача ищется по идентификатору сессии. Сессий, поднятых не с доски,
17
+ * большинство — для них здесь ничего не происходит, и это штатный ход, а не
18
+ * пропуск.
19
+ *
20
+ * @returns {{taskId: string, waiting: boolean}|undefined} что изменилось
21
+ */
22
+ export function handleSessionEvent({ store, sessionId, type }) {
23
+ const next = waitingFromEvent(type)
24
+ if (next === undefined) return undefined
25
+
26
+ const task = store.findTaskBySession(sessionId)
27
+ if (task === undefined) return undefined
28
+ if (task.waiting === next) return undefined
29
+
30
+ store.updateTask(task.id, { waiting: next })
31
+ return { taskId: task.id, waiting: next }
32
+ }