@goodandready/dsh-kanban 0.2.2 → 0.2.8

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/power.js CHANGED
@@ -134,7 +134,7 @@ export class PowerInhibitor {
134
134
  this.child.stdin.end()
135
135
  }
136
136
  this.child.kill()
137
- } catch {}
137
+ } catch { /* process kill best-effort */ }
138
138
  this.child = null
139
139
  }
140
140
  this.phase = 'disabled'
@@ -1,9 +1,9 @@
1
1
  // dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
2
2
  // Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
3
3
 
4
- export const BEGIN_MARKER = '<<<PROGRESSDUMP'
5
- export const END_MARKER = '>>>PROGRESSDUMP'
6
- export const FIELD_BYTE_LIMIT = 8 * 1024
4
+ const BEGIN_MARKER = '<<<PROGRESSDUMP'
5
+ const END_MARKER = '>>>PROGRESSDUMP'
6
+ const FIELD_BYTE_LIMIT = 8 * 1024
7
7
  export const REDACTED = '[REDACTED]'
8
8
 
9
9
  const SENSITIVE_PATTERNS = [
package/lib/routes.js CHANGED
@@ -19,12 +19,54 @@ import { computeNextRun } from './cron.js'
19
19
  /** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
20
20
  export const MAX_BODY_BYTES = 256 * 1024
21
21
 
22
+ function isLoopbackAddress(addr) {
23
+ if (!addr) return true
24
+ return addr === '127.0.0.1' ||
25
+ addr === '::1' ||
26
+ addr === '::ffff:127.0.0.1' ||
27
+ addr === 'localhost'
28
+ }
29
+
22
30
  /**
23
31
  * Запрос с чужого сайта. Изменяющие маршруты правят локальное состояние, и
24
- * пускать в них кросс-сайтовый запрос нельзя.
32
+ * пускать в них кросс-сайтовый запрос нельзя (#261).
33
+ * Защита требует:
34
+ * 1. Наличие безопасного sec-fetch-site ('same-origin', 'same-site', 'none').
35
+ * 2. Совпадение origin и host (если origin передан).
36
+ * 3. Loopback-источник (remoteAddress: 127.0.0.1, ::1, ::ffff:127.0.0.1).
25
37
  */
26
38
  export function isTrustedRequest(req) {
27
- return req?.headers?.['sec-fetch-site'] !== 'cross-site'
39
+ if (!req || !req.headers) return false
40
+
41
+ // 1. Проверка sec-fetch-site (отсутствие или cross-site запрещены)
42
+ const site = req.headers['sec-fetch-site']
43
+ if (!site || site === 'cross-site') {
44
+ return false
45
+ }
46
+ if (site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
47
+ return false
48
+ }
49
+
50
+ // 2. Сверка origin с host
51
+ const origin = req.headers.origin
52
+ if (origin !== undefined) {
53
+ if (origin === 'null') return false
54
+ try {
55
+ const parsed = new URL(origin)
56
+ const host = req.headers.host || ''
57
+ if (parsed.host && parsed.host !== host) return false
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ // 3. Проверка loopback remoteAddress
64
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
65
+ if (remote && !isLoopbackAddress(remote)) {
66
+ return false
67
+ }
68
+
69
+ return true
28
70
  }
29
71
 
30
72
  /** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
@@ -115,7 +157,13 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
115
157
  bySession.get(id).push(task)
116
158
  }
117
159
  const tasks = orderForBoard(
118
- raw.map((task) => withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])),
160
+ raw.map((task) => {
161
+ const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
162
+ if (task.isEpic) {
163
+ enriched.subtasksCount = store.countSubtasks(task.id)
164
+ }
165
+ return enriched
166
+ }),
119
167
  columnsOf(store.boardKind(board)),
120
168
  )
121
169
  const kind = store.boardKind(board)
@@ -226,7 +274,7 @@ export function confirmPermission({ store, id }) {
226
274
  }
227
275
 
228
276
  /** Известные приоритеты. Пустой — «без приоритета». */
229
- export const PRIORITIES = ['', 'high', 'medium', 'low']
277
+ const PRIORITIES = ['', 'high', 'medium', 'low']
230
278
 
231
279
  /**
232
280
  * Дописать заметку в тело задачи.
@@ -386,7 +434,7 @@ export function importBoard({ store, input }) {
386
434
  if (typeof store.backupDatabase === 'function') {
387
435
  try {
388
436
  backupMeta = store.backupDatabase()
389
- } catch {}
437
+ } catch { /* backup best effort */ }
390
438
  }
391
439
  if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
392
440
  if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
@@ -495,3 +543,353 @@ export function exportTasksCsv({ store, board = 'main' }) {
495
543
  // Префикс UTF-8 BOM для Microsoft Excel
496
544
  return '\uFEFF' + rows.join('\r\n')
497
545
  }
546
+
547
+
548
+ /**
549
+ * Очистить воркдерево задачи.
550
+ */
551
+ export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
552
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
553
+ const out = await cleanupTaskWorktree({
554
+ store,
555
+ task,
556
+ workspaceRegistry,
557
+ force: force === true,
558
+ })
559
+ return { status: 200, body: out }
560
+ }
561
+
562
+ /**
563
+ * Получить diff рабочей копии задачи.
564
+ */
565
+ export async function handleTaskDiff({ task, getTaskDiff }) {
566
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
567
+ const diffResult = await getTaskDiff({ worktreePath: task.worktree })
568
+ return { status: 200, body: diffResult }
569
+ }
570
+
571
+ /**
572
+ * Получить список коммитов задачи.
573
+ */
574
+ export async function handleTaskCommits({ task, getTaskCommits }) {
575
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
576
+ const commits = await getTaskCommits({ worktreePath: task.worktree })
577
+ return { status: 200, body: { commits } }
578
+ }
579
+
580
+ /**
581
+ * Слить воркдерево задачи и перевести в done.
582
+ */
583
+ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
584
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
585
+ const mergeResult = await mergeTaskWorktree({
586
+ task,
587
+ targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
588
+ workspaceRegistry,
589
+ })
590
+ if (!mergeResult.success) {
591
+ return {
592
+ status: 400,
593
+ body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
594
+ }
595
+ }
596
+ const updated = store.acceptTask(task.id)
597
+ store.addTransition({
598
+ taskId: task.id,
599
+ fromCol: task.column,
600
+ toCol: 'done',
601
+ source: 'manual',
602
+ detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
603
+ })
604
+ return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
605
+ }
606
+
607
+ /**
608
+ * Проверить статус воркдерева задачи (ветка, dirty, воркспейс).
609
+ */
610
+ export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
611
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
612
+ const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
613
+ return {
614
+ status: 200,
615
+ body: {
616
+ worktree: task.worktree || '',
617
+ branch: task.branch || '',
618
+ workspaceId: task.workspaceId || '',
619
+ dirty: dirtyCheck.dirty,
620
+ files: dirtyCheck.files,
621
+ },
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Обновить DoD чеклист задачи.
627
+ */
628
+ export function handleTaskChecklist({ store, task, input }) {
629
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
630
+ const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
631
+ const updated = store.updateChecklist(task.id, list)
632
+ return { status: 200, body: { task: updated } }
633
+ }
634
+
635
+ /**
636
+ * Установить отчёт выполнения задачи.
637
+ */
638
+ export function handleTaskReport({ store, task, input }) {
639
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
640
+ const updated = store.setReport(task.id, input)
641
+ return { status: 200, body: { task: updated } }
642
+ }
643
+
644
+ /**
645
+ * Получить комментарии задачи.
646
+ */
647
+ export function handleGetTaskComments({ task }) {
648
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
649
+ return { status: 200, body: { comments: task.comments || [] } }
650
+ }
651
+
652
+ /**
653
+ * Добавить комментарий к задаче.
654
+ */
655
+ export function handleAddTaskComment({ store, task, input }) {
656
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
657
+ const author = input?.author || 'user'
658
+ const role = input?.role || 'user'
659
+ const text = input?.text || ''
660
+ const updated = store.addComment(task.id, { author, role, text })
661
+ return { status: 200, body: { task: updated } }
662
+ }
663
+
664
+ /**
665
+ * Принять задачу на ревью (перевод в done).
666
+ */
667
+ export function handleAcceptTask({ store, task }) {
668
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
669
+ const updated = store.acceptTask(task.id)
670
+ return { status: 200, body: { task: updated } }
671
+ }
672
+
673
+ /**
674
+ * Отклонить задачу на ревью (перевод обратно в in_progress).
675
+ */
676
+ export function handleRejectTask({ store, task, input }) {
677
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
678
+ const reason = String(input?.reason || '').trim()
679
+ if (!reason) return { status: 400, body: { error: 'reason-required' } }
680
+ const updated = store.rejectTask(task.id, reason)
681
+ return { status: 200, body: { task: updated } }
682
+ }
683
+
684
+ /**
685
+ * Сохранить дамп прогресса задачи.
686
+ */
687
+ export function handleProgressDump({ store, task, input, formatProgressDump }) {
688
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
689
+ const formatted = typeof input === 'string' ? input : formatProgressDump(input)
690
+ const updated = store.updateTask(task.id, { progressDump: formatted })
691
+ return { status: 200, body: { task: updated } }
692
+ }
693
+
694
+ /**
695
+ * Decompose a task into subtasks and make the parent an Epic.
696
+ */
697
+ export async function handleTriageTask({ store, gitea, task, input }) {
698
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
699
+ const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
700
+ if (subtasksInput.length === 0) {
701
+ return { status: 400, body: { error: 'subtasks-required' } }
702
+ }
703
+ const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
704
+
705
+ // Convert parent into Epic
706
+ store.updateTask(task.id, { isEpic: true })
707
+
708
+ const created = []
709
+ for (const item of subtasksInput) {
710
+ const title = String(item?.title ?? '').trim()
711
+ if (!title) continue
712
+ const body = String(item?.body ?? '').trim()
713
+ let issueNumber = null
714
+ let issueUrl = null
715
+
716
+ if (createGitea) {
717
+ try {
718
+ const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
719
+ const giteaIssue = await gitea.createIssue({
720
+ owner: task.owner,
721
+ repo: task.repo,
722
+ title,
723
+ body: issueBody,
724
+ })
725
+ if (giteaIssue?.number) {
726
+ issueNumber = giteaIssue.number
727
+ issueUrl = giteaIssue.html_url || null
728
+ }
729
+ } catch { /* ignore gitea subtask creation failure */ }
730
+ }
731
+
732
+ const sub = store.createTask({
733
+ board: task.board || 'main',
734
+ column: 'backlog',
735
+ title,
736
+ body,
737
+ parentId: task.id,
738
+ owner: task.owner || null,
739
+ repo: task.repo || null,
740
+ issueNumber,
741
+ issueUrl,
742
+ labels: Array.isArray(item?.labels) ? item.labels : [],
743
+ workspaceId: task.workspaceId || '',
744
+ })
745
+ created.push(sub)
746
+ }
747
+
748
+ store.addTransition({
749
+ taskId: task.id,
750
+ fromCol: task.column,
751
+ toCol: task.column,
752
+ source: 'manual',
753
+ detail: `decomposed into ${created.length} subtasks (epic triage)`,
754
+ })
755
+
756
+ return {
757
+ status: 200,
758
+ body: {
759
+ epic: store.getTask(task.id),
760
+ subtasks: store.listSubtasks(task.id),
761
+ counts: store.countSubtasks(task.id),
762
+ createdCount: created.length,
763
+ },
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Get subtasks of a task.
769
+ */
770
+ export function handleGetSubtasks({ store, task }) {
771
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
772
+ return {
773
+ status: 200,
774
+ body: {
775
+ subtasks: store.listSubtasks(task.id),
776
+ counts: store.countSubtasks(task.id),
777
+ },
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Create a single subtask linked to a parent task.
783
+ */
784
+ export async function handleCreateSubtask({ store, gitea, task, input }) {
785
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
786
+ const title = String(input?.title ?? '').trim()
787
+ if (!title) return { status: 400, body: { error: 'title-required' } }
788
+ const body = String(input?.body ?? '').trim()
789
+ let issueNumber = null
790
+ let issueUrl = null
791
+
792
+ if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
793
+ try {
794
+ const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
795
+ const giteaIssue = await gitea.createIssue({
796
+ owner: task.owner,
797
+ repo: task.repo,
798
+ title,
799
+ body: issueBody,
800
+ })
801
+ if (giteaIssue?.number) {
802
+ issueNumber = giteaIssue.number
803
+ issueUrl = giteaIssue.html_url || null
804
+ }
805
+ } catch { /* ignore gitea subtask creation failure */ }
806
+ }
807
+
808
+ store.updateTask(task.id, { isEpic: true })
809
+ const sub = store.createTask({
810
+ board: task.board || 'main',
811
+ column: input?.column || 'backlog',
812
+ title,
813
+ body,
814
+ parentId: task.id,
815
+ owner: task.owner || null,
816
+ repo: task.repo || null,
817
+ issueNumber,
818
+ issueUrl,
819
+ labels: Array.isArray(input?.labels) ? input.labels : [],
820
+ workspaceId: task.workspaceId || '',
821
+ })
822
+ return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
823
+ }
824
+
825
+ /**
826
+ * Toggle a checklist item atomically by index or text.
827
+ */
828
+ export function handleToggleChecklistItem({ store, task, input }) {
829
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
830
+ const current = Array.isArray(task.checklist) ? [...task.checklist] : []
831
+ let targetIndex = -1
832
+
833
+ if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
834
+ targetIndex = input.index
835
+ } else if (typeof input?.text === 'string' && input.text.trim()) {
836
+ const q = input.text.trim().toLowerCase()
837
+ targetIndex = current.findIndex((item) => {
838
+ const t = typeof item === 'string' ? item : item?.text
839
+ return typeof t === 'string' && t.toLowerCase().includes(q)
840
+ })
841
+ }
842
+
843
+ if (targetIndex === -1) {
844
+ return { status: 404, body: { error: 'checklist-item-not-found' } }
845
+ }
846
+
847
+ const item = current[targetIndex]
848
+ let updatedItem
849
+ if (typeof item === 'string') {
850
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : true
851
+ updatedItem = { text: item, done: checked }
852
+ } else {
853
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
854
+ updatedItem = { ...item, done: checked }
855
+ }
856
+ current[targetIndex] = updatedItem
857
+ const updated = store.updateChecklist(task.id, current)
858
+ return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
859
+ }
860
+
861
+ /**
862
+ * Create a Gitea Pull Request for a task.
863
+ */
864
+ export async function handleCreateTaskPr({ store, gitea, task, input }) {
865
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
866
+ if (!task.owner || !task.repo) {
867
+ return { status: 400, body: { error: 'task-no-repo' } }
868
+ }
869
+ const head = String(input?.head || task.branch || '').trim()
870
+ if (!head) return { status: 400, body: { error: 'branch-required' } }
871
+ const base = String(input?.base || 'main').trim()
872
+ const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
873
+ const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
874
+
875
+ try {
876
+ const pr = await gitea.createPullRequest({
877
+ owner: task.owner,
878
+ repo: task.repo,
879
+ title,
880
+ body,
881
+ head,
882
+ base,
883
+ })
884
+ store.addTransition({
885
+ taskId: task.id,
886
+ fromCol: task.column,
887
+ toCol: task.column,
888
+ source: 'manual',
889
+ detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
890
+ })
891
+ return { status: 200, body: { pr, prUrl: pr?.html_url } }
892
+ } catch (err) {
893
+ return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
894
+ }
895
+ }
package/lib/store.js CHANGED
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS tasks (
37
37
  repo TEXT,
38
38
  issueNumber INTEGER,
39
39
  issueUrl TEXT,
40
+ externalRef TEXT,
40
41
  labels TEXT NOT NULL DEFAULT '[]',
41
42
  branch TEXT,
42
43
  worktree TEXT,
@@ -70,6 +71,8 @@ CREATE TABLE IF NOT EXISTS tasks (
70
71
  comments TEXT NOT NULL DEFAULT '[]',
71
72
  rejectReason TEXT NOT NULL DEFAULT '',
72
73
  rejectedAt INTEGER NOT NULL DEFAULT 0,
74
+ parentId TEXT NOT NULL DEFAULT '',
75
+ isEpic INTEGER NOT NULL DEFAULT 0,
73
76
  updatedAt INTEGER NOT NULL
74
77
  );
75
78
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -95,10 +98,11 @@ CREATE TABLE IF NOT EXISTS dismissed (
95
98
 
96
99
  /** Поля задачи, которые разрешено править через updateTask. */
97
100
  const WRITABLE = [
98
- 'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
101
+ 'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'externalRef', 'labels',
99
102
  'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
100
103
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
101
- 'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt', 'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt',
104
+ 'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
105
+ 'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
102
106
  ]
103
107
 
104
108
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
@@ -106,7 +110,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
106
110
 
107
111
  function rowToTask(row) {
108
112
  if (row === undefined) return undefined
109
- const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, ...rest } = row
113
+ const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
110
114
  let parsedChecklist = []
111
115
  try {
112
116
  const value = JSON.parse(checklist ?? '[]')
@@ -150,6 +154,8 @@ function rowToTask(row) {
150
154
  rejectReason: rejectReason ?? '',
151
155
  rejectedAt: Number(rejectedAt) || 0,
152
156
  reasoningEffort: rest.reasoningEffort ?? '',
157
+ isEpic: isEpic === 1,
158
+ parentId: parentId ?? '',
153
159
  }
154
160
  }
155
161
 
@@ -163,6 +169,8 @@ export function openStore({ dir }) {
163
169
  const db = new DatabaseSync(join(dir, 'kanban.db'))
164
170
  db.exec('PRAGMA journal_mode = WAL')
165
171
  db.exec('PRAGMA foreign_keys = ON')
172
+ db.exec('PRAGMA busy_timeout = 5000')
173
+ db.exec('PRAGMA busy_timeout = 5000')
166
174
  db.exec(SCHEMA)
167
175
  // База могла быть создана прежней версией. Добавляем недостающие столбцы
168
176
  // поштучно: ALTER на существующий столбец бросает, и это штатный ответ
@@ -194,6 +202,9 @@ export function openStore({ dir }) {
194
202
  ['comments', "TEXT NOT NULL DEFAULT '[]'"],
195
203
  ['rejectReason', "TEXT NOT NULL DEFAULT ''"],
196
204
  ['rejectedAt', "INTEGER NOT NULL DEFAULT 0"],
205
+ ['parentId', "TEXT NOT NULL DEFAULT ''"],
206
+ ['isEpic', "INTEGER NOT NULL DEFAULT 0"],
207
+ ['externalRef', "TEXT"],
197
208
  ]) {
198
209
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
199
210
  }
@@ -202,6 +213,16 @@ export function openStore({ dir }) {
202
213
  // заведения: это честнее нуля, который читался бы как «в 1970 году».
203
214
  db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
204
215
 
216
+ // Индексы для ускорения выборок активных задач, поиска по Gitea issue и cron-задач
217
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
218
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
219
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_external_ref ON tasks(externalRef)')
220
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_cron_next ON tasks(archivedAt, nextRunAt)')
221
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_queued ON tasks(archivedAt, queuedAt)')
222
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
223
+ db.exec('PRAGMA synchronous = NORMAL')
224
+ try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
225
+
205
226
  const q = {
206
227
  boards: db.prepare('SELECT * FROM boards ORDER BY position'),
207
228
  boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
@@ -217,6 +238,7 @@ export function openStore({ dir }) {
217
238
  dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
218
239
  isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
219
240
  byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
241
+ byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
220
242
  tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
221
243
  lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
222
244
  deleteTask: db.prepare('DELETE FROM tasks WHERE id = ?'),
@@ -227,6 +249,9 @@ export function openStore({ dir }) {
227
249
  "SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
228
250
  scheduledDue: db.prepare(
229
251
  "SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
252
+ subtasksOf: db.prepare('SELECT * FROM tasks WHERE parentId = ? AND archivedAt = 0 ORDER BY createdAt ASC, position ASC'),
253
+ countSubtasks: db.prepare(
254
+ "SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
230
255
  }
231
256
 
232
257
  function ensureBoard(id, title, kind) {
@@ -279,10 +304,10 @@ export function openStore({ dir }) {
279
304
  .sort((a, b) => b.time - a.time)
280
305
  if (files.length > 10) {
281
306
  for (const old of files.slice(10)) {
282
- try { unlinkSync(old.path) } catch {}
307
+ try { unlinkSync(old.path) } catch { /* unlink old backup best-effort */ }
283
308
  }
284
309
  }
285
- } catch {}
310
+ } catch { /* backup rotation best-effort */ }
286
311
  return { backup: backupName, path: dest }
287
312
  },
288
313
 
@@ -354,16 +379,16 @@ export function openStore({ dir }) {
354
379
  const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
355
380
  const position = positionFor(board, column, input.beforeId, input.afterId)
356
381
  db.prepare(`INSERT INTO tasks
357
- (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
382
+ (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
358
383
  labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
359
384
  labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
360
385
  cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
361
- checklist, report, comments, rejectReason, rejectedAt, reasoningEffort)
362
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
386
+ checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
387
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
363
388
  id, board, column, position,
364
389
  input.title ?? '', input.body ?? '',
365
390
  input.owner ?? null, input.repo ?? null,
366
- input.issueNumber ?? null, input.issueUrl ?? null,
391
+ input.issueNumber ?? null, input.issueUrl ?? null, input.externalRef ?? null,
367
392
  JSON.stringify(input.labels ?? []),
368
393
  input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
369
394
  input.sessionId ?? null, input.model ?? null, input.provider ?? null,
@@ -389,6 +414,8 @@ export function openStore({ dir }) {
389
414
  input.rejectReason ?? '',
390
415
  Number(input.rejectedAt) || 0,
391
416
  typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
417
+ input.parentId ?? '',
418
+ input.isEpic ? 1 : 0,
392
419
  )
393
420
  return rowToTask(q.taskById.get(id))
394
421
  },
@@ -411,6 +438,8 @@ export function openStore({ dir }) {
411
438
  else if (key === 'rejectReason') values.push(String(patch[key] ?? ''))
412
439
  else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
413
440
  else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
441
+ else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
442
+ else if (key === 'parentId') values.push(String(patch[key] ?? ''))
414
443
  else values.push(patch[key] ?? null)
415
444
  }
416
445
  if (fields.length > 0) {
@@ -546,6 +575,27 @@ export function openStore({ dir }) {
546
575
  return rowToTask(q.byIssue.get(owner, repo, issueNumber))
547
576
  },
548
577
 
578
+ findTaskByExternalRef(externalRef) {
579
+ const key = typeof externalRef === 'string' ? externalRef.trim() : ''
580
+ if (!key) return undefined
581
+ return rowToTask(q.byExternalRef.get(key))
582
+ },
583
+
584
+ listSubtasks(parentId) {
585
+ if (!parentId) return []
586
+ return q.subtasksOf.all(parentId).map(rowToTask)
587
+ },
588
+
589
+ countSubtasks(parentId) {
590
+ if (!parentId) return { total: 0, completed: 0 }
591
+ const res = q.countSubtasks.get(parentId)
592
+ return { total: res?.total ?? 0, completed: res?.completed ?? 0 }
593
+ },
594
+
595
+ setParent(id, parentId) {
596
+ return this.updateTask(id, { parentId: parentId ?? '' })
597
+ },
598
+
549
599
  /** Задачи в архиве: свой список, а не колонка. */
550
600
  listArchived() {
551
601
  return q.archived.all().map(rowToTask)