@goodandready/dsh-kanban 0.1.24 → 0.1.26
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/README.md +17 -0
- package/docs/design/DESIGN.md +2 -0
- package/lib/client.js +162 -2
- package/lib/config.js +16 -0
- package/lib/cron.js +154 -0
- package/lib/index.js +187 -6
- package/lib/launcher.js +116 -7
- package/lib/lifecycle.js +34 -5
- package/lib/power.js +157 -0
- package/lib/progress-dump.js +134 -0
- package/lib/routes.js +22 -1
- package/lib/store.js +65 -7
- package/lib/worktree.js +339 -0
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
|
|
2
|
+
// Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
|
|
3
|
+
|
|
4
|
+
export const BEGIN_MARKER = '<<<PROGRESSDUMP'
|
|
5
|
+
export const END_MARKER = '>>>PROGRESSDUMP'
|
|
6
|
+
export const FIELD_BYTE_LIMIT = 8 * 1024
|
|
7
|
+
export const REDACTED = '[REDACTED]'
|
|
8
|
+
|
|
9
|
+
const SENSITIVE_PATTERNS = [
|
|
10
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
11
|
+
/Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
|
|
12
|
+
/sk-[A-Za-z0-9_-]{8,}/g,
|
|
13
|
+
/ghp_[A-Za-z0-9]{20,}/g,
|
|
14
|
+
/glpat-[A-Za-z0-9_-]{15,}/g,
|
|
15
|
+
/xox[bpars]-[A-Za-z0-9-]{10,}/g,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
function sanitizeText(raw) {
|
|
19
|
+
let text = raw
|
|
20
|
+
let redacted = false
|
|
21
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
22
|
+
if (pattern.test(text)) {
|
|
23
|
+
redacted = true
|
|
24
|
+
text = text.replace(pattern, REDACTED)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Блокируем внедрение слэш-команд харнесса
|
|
28
|
+
text = text.split('\n')
|
|
29
|
+
.map((line) => (line.trim().startsWith('/') ? ('# ' + line) : line))
|
|
30
|
+
.join('\n')
|
|
31
|
+
|
|
32
|
+
return { text, redacted }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncateBytes(str, maxBytes) {
|
|
36
|
+
const buf = Buffer.from(str, 'utf8')
|
|
37
|
+
if (buf.length <= maxBytes) return str
|
|
38
|
+
return buf.subarray(0, maxBytes).toString('utf8')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Разобрать блок PROGRESSDUMP из произвольного текста.
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {{ ok: true, dump: { goal: string, progress: string, next: string, redacted: boolean } } | { ok: false, error: string }}
|
|
45
|
+
*/
|
|
46
|
+
export function parseProgressDump(text) {
|
|
47
|
+
if (typeof text !== 'string') return { ok: false, error: 'text-required' }
|
|
48
|
+
|
|
49
|
+
const startIdx = text.indexOf(BEGIN_MARKER)
|
|
50
|
+
const endIdx = text.indexOf(END_MARKER)
|
|
51
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
52
|
+
return { ok: false, error: 'markers-not-found' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const inner = text.slice(startIdx + BEGIN_MARKER.length, endIdx).trim()
|
|
56
|
+
const lines = inner.split('\n')
|
|
57
|
+
|
|
58
|
+
let currentKey = null
|
|
59
|
+
const sections = { goal: [], progress: [], next: [] }
|
|
60
|
+
|
|
61
|
+
for (const rawLine of lines) {
|
|
62
|
+
const line = rawLine.trim()
|
|
63
|
+
const lower = line.toLowerCase()
|
|
64
|
+
|
|
65
|
+
if (lower.startsWith('цель:') || lower.startsWith('goal:')) {
|
|
66
|
+
currentKey = 'goal'
|
|
67
|
+
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
68
|
+
if (val) sections.goal.push(val)
|
|
69
|
+
} else if (lower.startsWith('прогресс:') || lower.startsWith('progress:')) {
|
|
70
|
+
currentKey = 'progress'
|
|
71
|
+
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
72
|
+
if (val) sections.progress.push(val)
|
|
73
|
+
} else if (lower.startsWith('следующие шаги:') || lower.startsWith('next:') || lower.startsWith('шаги:')) {
|
|
74
|
+
currentKey = 'next'
|
|
75
|
+
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
76
|
+
if (val) sections.next.push(val)
|
|
77
|
+
} else if (currentKey) {
|
|
78
|
+
sections[currentKey].push(rawLine)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let totalRedacted = false
|
|
83
|
+
const result = { goal: '', progress: '', next: '', redacted: false }
|
|
84
|
+
|
|
85
|
+
for (const key of ['goal', 'progress', 'next']) {
|
|
86
|
+
const rawVal = sections[key].join('\n').trim()
|
|
87
|
+
const { text: cleanText, redacted } = sanitizeText(rawVal)
|
|
88
|
+
if (redacted) totalRedacted = true
|
|
89
|
+
result[key] = truncateBytes(cleanText, FIELD_BYTE_LIMIT)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
result.redacted = totalRedacted
|
|
93
|
+
return { ok: true, dump: result }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Сериализовать срез в канонический блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP.
|
|
98
|
+
*/
|
|
99
|
+
export function formatProgressDump(dump) {
|
|
100
|
+
if (!dump) return ''
|
|
101
|
+
return [
|
|
102
|
+
BEGIN_MARKER,
|
|
103
|
+
'Цель:',
|
|
104
|
+
dump.goal || '—',
|
|
105
|
+
'',
|
|
106
|
+
'Прогресс:',
|
|
107
|
+
dump.progress || '—',
|
|
108
|
+
'',
|
|
109
|
+
'Следующие шаги:',
|
|
110
|
+
dump.next || '—',
|
|
111
|
+
END_MARKER,
|
|
112
|
+
].join('\n')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Сформировать аккуратный вводный бриф для новой сессии агента.
|
|
117
|
+
*/
|
|
118
|
+
export function formatHandoverPreamble(dump) {
|
|
119
|
+
if (!dump || (!dump.goal && !dump.progress && !dump.next)) return ''
|
|
120
|
+
return [
|
|
121
|
+
'## 📋 Эстафета задачи (PROGRESSDUMP)',
|
|
122
|
+
'> [!NOTE]',
|
|
123
|
+
'> Предыдущий ход зафиксировал текущее состояние работы:',
|
|
124
|
+
'',
|
|
125
|
+
'**Цель:** ' + (dump.goal || '—'),
|
|
126
|
+
'',
|
|
127
|
+
'**Сделано:** ' + (dump.progress || '—'),
|
|
128
|
+
'',
|
|
129
|
+
'**Следующие шаги:** ' + (dump.next || '—'),
|
|
130
|
+
'',
|
|
131
|
+
'---',
|
|
132
|
+
'',
|
|
133
|
+
].join('\n')
|
|
134
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -14,6 +14,7 @@ import { columnStats, doneWithin, stale } from './metrics.js'
|
|
|
14
14
|
import { isStale } from './time.js'
|
|
15
15
|
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
|
|
16
16
|
import { parseBody } from './markdown.js'
|
|
17
|
+
import { computeNextRun } from './cron.js'
|
|
17
18
|
|
|
18
19
|
/** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
|
|
19
20
|
export const MAX_BODY_BYTES = 256 * 1024
|
|
@@ -183,7 +184,8 @@ export function createTask({ store, input }) {
|
|
|
183
184
|
|
|
184
185
|
/** Правка полей карточки. Колонку и порядок правит только перенос. */
|
|
185
186
|
export function updateTask({ store, id, input }) {
|
|
186
|
-
|
|
187
|
+
const current = store.getTask(id)
|
|
188
|
+
if (current === undefined) return { error: 'task-not-found', status: 404 }
|
|
187
189
|
const patch = {}
|
|
188
190
|
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
189
191
|
if (typeof input?.body === 'string') patch.body = input.body
|
|
@@ -197,9 +199,28 @@ export function updateTask({ store, id, input }) {
|
|
|
197
199
|
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
198
200
|
patch.dueAt = Math.floor(input.dueAt)
|
|
199
201
|
}
|
|
202
|
+
if (typeof input?.cron === 'string') {
|
|
203
|
+
patch.cron = input.cron.trim()
|
|
204
|
+
patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
|
|
205
|
+
}
|
|
206
|
+
if (typeof input?.progressDump === 'string') {
|
|
207
|
+
patch.progressDump = input.progressDump
|
|
208
|
+
}
|
|
209
|
+
if (typeof input?.permission === 'string') {
|
|
210
|
+
patch.permission = input.permission
|
|
211
|
+
if (input.permission !== current.permission) {
|
|
212
|
+
patch.permissionConfirmedAt = 0
|
|
213
|
+
}
|
|
214
|
+
}
|
|
200
215
|
return { task: store.updateTask(id, patch) }
|
|
201
216
|
}
|
|
202
217
|
|
|
218
|
+
export function confirmPermission({ store, id }) {
|
|
219
|
+
const task = store.getTask(id)
|
|
220
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
221
|
+
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
/** Известные приоритеты. Пустой — «без приоритета». */
|
|
204
225
|
export const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
205
226
|
|
package/lib/store.js
CHANGED
|
@@ -40,6 +40,14 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
40
40
|
labels TEXT NOT NULL DEFAULT '[]',
|
|
41
41
|
branch TEXT,
|
|
42
42
|
worktree TEXT,
|
|
43
|
+
workspaceId TEXT NOT NULL DEFAULT '',
|
|
44
|
+
cron TEXT NOT NULL DEFAULT '',
|
|
45
|
+
nextRunAt INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
lastRunAt INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
progressDump TEXT NOT NULL DEFAULT '',
|
|
48
|
+
permission TEXT NOT NULL DEFAULT '',
|
|
49
|
+
permissionConfirmedAt INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
runs TEXT NOT NULL DEFAULT '[]',
|
|
43
51
|
sessionId TEXT,
|
|
44
52
|
model TEXT,
|
|
45
53
|
provider TEXT,
|
|
@@ -82,8 +90,9 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
82
90
|
/** Поля задачи, которые разрешено править через updateTask. */
|
|
83
91
|
const WRITABLE = [
|
|
84
92
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
85
|
-
'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
93
|
+
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
86
94
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
95
|
+
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs',
|
|
87
96
|
]
|
|
88
97
|
|
|
89
98
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -91,7 +100,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
|
|
|
91
100
|
|
|
92
101
|
function rowToTask(row) {
|
|
93
102
|
if (row === undefined) return undefined
|
|
94
|
-
const { col, labels, waiting, labelColors, ...rest } = row
|
|
103
|
+
const { col, labels, waiting, labelColors, runs, ...rest } = row
|
|
95
104
|
let parsed = []
|
|
96
105
|
try {
|
|
97
106
|
const value = JSON.parse(labels)
|
|
@@ -102,7 +111,12 @@ function rowToTask(row) {
|
|
|
102
111
|
const value = JSON.parse(labelColors ?? '{}')
|
|
103
112
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
|
|
104
113
|
} catch { /* испорченное поле не должно ронять чтение всей доски */ }
|
|
105
|
-
|
|
114
|
+
let parsedRuns = []
|
|
115
|
+
try {
|
|
116
|
+
const value = JSON.parse(runs ?? '[]')
|
|
117
|
+
if (Array.isArray(value)) parsedRuns = value
|
|
118
|
+
} catch { /* испорченное поле не должно ронять чтение всей доски */ }
|
|
119
|
+
return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1, runs: parsedRuns }
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
/**
|
|
@@ -132,6 +146,14 @@ export function openStore({ dir }) {
|
|
|
132
146
|
['milestone', "TEXT NOT NULL DEFAULT ''"],
|
|
133
147
|
['priority', "TEXT NOT NULL DEFAULT ''"],
|
|
134
148
|
['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
149
|
+
['workspaceId', "TEXT NOT NULL DEFAULT ''"],
|
|
150
|
+
['cron', "TEXT NOT NULL DEFAULT ''"],
|
|
151
|
+
['nextRunAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
152
|
+
['lastRunAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
153
|
+
['progressDump', "TEXT NOT NULL DEFAULT ''"],
|
|
154
|
+
['permission', "TEXT NOT NULL DEFAULT ''"],
|
|
155
|
+
['permissionConfirmedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
156
|
+
['runs', "TEXT NOT NULL DEFAULT '[]'"],
|
|
135
157
|
]) {
|
|
136
158
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
137
159
|
}
|
|
@@ -163,6 +185,8 @@ export function openStore({ dir }) {
|
|
|
163
185
|
countInColumn: db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE board = ? AND col = ?'),
|
|
164
186
|
watchable: db.prepare(
|
|
165
187
|
"SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
|
|
188
|
+
scheduledDue: db.prepare(
|
|
189
|
+
"SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
|
|
166
190
|
}
|
|
167
191
|
|
|
168
192
|
function ensureBoard(id, title, kind) {
|
|
@@ -260,15 +284,16 @@ export function openStore({ dir }) {
|
|
|
260
284
|
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
261
285
|
db.prepare(`INSERT INTO tasks
|
|
262
286
|
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
263
|
-
labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
|
|
264
|
-
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt
|
|
265
|
-
|
|
287
|
+
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
288
|
+
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
289
|
+
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs)
|
|
290
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
266
291
|
id, board, column, position,
|
|
267
292
|
input.title ?? '', input.body ?? '',
|
|
268
293
|
input.owner ?? null, input.repo ?? null,
|
|
269
294
|
input.issueNumber ?? null, input.issueUrl ?? null,
|
|
270
295
|
JSON.stringify(input.labels ?? []),
|
|
271
|
-
input.branch ?? null, input.worktree ?? null,
|
|
296
|
+
input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
|
|
272
297
|
input.sessionId ?? null, input.model ?? null, input.provider ?? null,
|
|
273
298
|
input.waiting ? 1 : 0,
|
|
274
299
|
Number(input.syncedAt) || 0,
|
|
@@ -279,6 +304,13 @@ export function openStore({ dir }) {
|
|
|
279
304
|
input.priority ?? '',
|
|
280
305
|
Number(input.dueAt) || 0,
|
|
281
306
|
now, born, now,
|
|
307
|
+
input.cron ?? '',
|
|
308
|
+
Number(input.nextRunAt) || 0,
|
|
309
|
+
Number(input.lastRunAt) || 0,
|
|
310
|
+
input.progressDump ?? '',
|
|
311
|
+
input.permission ?? '',
|
|
312
|
+
Number(input.permissionConfirmedAt) || 0,
|
|
313
|
+
typeof input.runs === 'string' ? input.runs : JSON.stringify(input.runs ?? []),
|
|
282
314
|
)
|
|
283
315
|
return rowToTask(q.taskById.get(id))
|
|
284
316
|
},
|
|
@@ -293,6 +325,7 @@ export function openStore({ dir }) {
|
|
|
293
325
|
fields.push(`${key} = ?`)
|
|
294
326
|
if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
|
|
295
327
|
else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
|
|
328
|
+
else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
|
|
296
329
|
else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
|
|
297
330
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
298
331
|
else values.push(patch[key] ?? null)
|
|
@@ -304,6 +337,31 @@ export function openStore({ dir }) {
|
|
|
304
337
|
return rowToTask(q.taskById.get(id))
|
|
305
338
|
},
|
|
306
339
|
|
|
340
|
+
listDueTasks(now = Date.now()) {
|
|
341
|
+
return q.scheduledDue.all(now).map(rowToTask)
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
recordTaskRun(id, run) {
|
|
345
|
+
const task = rowToTask(q.taskById.get(id))
|
|
346
|
+
if (!task) return undefined
|
|
347
|
+
const runs = Array.isArray(task.runs) ? [...task.runs] : []
|
|
348
|
+
runs.unshift(run)
|
|
349
|
+
const trimmed = runs.slice(0, 20)
|
|
350
|
+
this.updateTask(id, { runs: JSON.stringify(trimmed) })
|
|
351
|
+
return rowToTask(q.taskById.get(id))
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
updateTaskRun(id, runId, patch) {
|
|
355
|
+
const task = rowToTask(q.taskById.get(id))
|
|
356
|
+
if (!task) return undefined
|
|
357
|
+
const runs = Array.isArray(task.runs) ? [...task.runs] : []
|
|
358
|
+
const idx = runs.findIndex((r) => r.id === runId)
|
|
359
|
+
if (idx === -1) return undefined
|
|
360
|
+
runs[idx] = { ...runs[idx], ...patch }
|
|
361
|
+
this.updateTask(id, { runs: JSON.stringify(runs) })
|
|
362
|
+
return rowToTask(q.taskById.get(id))
|
|
363
|
+
},
|
|
364
|
+
|
|
307
365
|
moveTask(id, { column, beforeId, afterId }) {
|
|
308
366
|
const current = q.taskById.get(id)
|
|
309
367
|
if (current === undefined) throw new Error(`задача ${id} не найдена`)
|
package/lib/worktree.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Управляемые воркдеревья для сессий агентов под задачи (Issue #193).
|
|
2
|
+
//
|
|
3
|
+
// Изоляция сессии агента в отдельном git worktree защищает основной
|
|
4
|
+
// репозиторий от параллельных правок нескольких задач, предотвращает
|
|
5
|
+
// перезапись веток и фиксирует рабочее окружение карточки.
|
|
6
|
+
//
|
|
7
|
+
// Жизненный цикл:
|
|
8
|
+
// 1. При переходе в In Progress создаётся воркдерево task/<id>-<slug>
|
|
9
|
+
// 2. Воркдерево регистрируется в DSH workspaceRegistry как рабочий каталог
|
|
10
|
+
// 3. Сессия агента запускается с cwd = worktreePath
|
|
11
|
+
// 4. В колонке Cleanup проверяются незакоммиченные файлы (dirtyFiles)
|
|
12
|
+
// 5. После подтверждения воркдерево удаляется, воркспейс дерегистрируется.
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process'
|
|
15
|
+
import { promisify } from 'node:util'
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Стандартный исполнитель git-команд.
|
|
23
|
+
* Принимает аргументы массивом, исключая шелл-инъекции.
|
|
24
|
+
*/
|
|
25
|
+
export async function defaultGitRunner(args, cwd) {
|
|
26
|
+
try {
|
|
27
|
+
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
28
|
+
cwd,
|
|
29
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
30
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
31
|
+
})
|
|
32
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 }
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const error = new Error(`git ${args.join(' ')} failed: ${err.stderr || err.message}`)
|
|
35
|
+
error.code = err.code || 1
|
|
36
|
+
error.stdout = err.stdout ? String(err.stdout).trim() : ''
|
|
37
|
+
error.stderr = err.stderr ? String(err.stderr).trim() : ''
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Преобразовать строку в безопасный фрагмент имени ветки или каталога.
|
|
44
|
+
*/
|
|
45
|
+
export function slugify(text) {
|
|
46
|
+
if (typeof text !== 'string') return 'task'
|
|
47
|
+
const slug = text
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.normalize('NFD')
|
|
50
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
51
|
+
.replace(/[^a-z0-9а-яё_-]+/gi, '-')
|
|
52
|
+
.replace(/-+/g, '-')
|
|
53
|
+
.replace(/^-|-$/g, '')
|
|
54
|
+
.slice(0, 40)
|
|
55
|
+
return slug || 'task'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Безопасный ключ репозитория для каталога воркдеревьев.
|
|
60
|
+
*/
|
|
61
|
+
export function safeRepoKey(task) {
|
|
62
|
+
const owner = typeof task?.owner === 'string' ? task.owner.trim() : ''
|
|
63
|
+
const repo = typeof task?.repo === 'string' ? task.repo.trim() : ''
|
|
64
|
+
if (owner && repo) return `${owner}_${repo}`
|
|
65
|
+
if (repo) return repo
|
|
66
|
+
return 'local'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Определить корневой каталог для всех управляемых воркдеревьев.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
|
|
73
|
+
const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
|
|
74
|
+
if (custom !== '') {
|
|
75
|
+
return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
|
|
76
|
+
}
|
|
77
|
+
if (typeof env?.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') {
|
|
78
|
+
return resolve(join(env.DSH_HOME.trim(), 'worktrees'))
|
|
79
|
+
}
|
|
80
|
+
const projectRoot = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
|
|
81
|
+
? config.defaultProjectRoot.trim()
|
|
82
|
+
: cwdOf()
|
|
83
|
+
return resolve(join(projectRoot, '.worktrees'))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Вычислить канонический путь к воркдереву задачи с защитой от path traversal.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
90
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
91
|
+
const repoKey = safeRepoKey(task)
|
|
92
|
+
const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
|
|
93
|
+
const target = resolve(join(root, repoKey, taskId))
|
|
94
|
+
|
|
95
|
+
const fenceSep = root.endsWith(sep) || root.endsWith('/') ? root : root + sep
|
|
96
|
+
if (target !== root && !target.startsWith(fenceSep) && !target.startsWith(root + '/')) {
|
|
97
|
+
throw new Error('путь воркдерева выходит за пределы корня воркдеревьев')
|
|
98
|
+
}
|
|
99
|
+
return target
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Сформировать имя ветки задачи.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveTaskBranch(task) {
|
|
106
|
+
const prefix = typeof task?.issueNumber === 'number'
|
|
107
|
+
? String(task.issueNumber)
|
|
108
|
+
: (typeof task?.id === 'string' ? task.id.slice(0, 8) : 'task')
|
|
109
|
+
const slug = slugify(task?.title || '')
|
|
110
|
+
return `task/${prefix}-${slug}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Проверить, является ли каталог git-репозиторием.
|
|
115
|
+
*/
|
|
116
|
+
export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
117
|
+
if (!repoDir) return false
|
|
118
|
+
try {
|
|
119
|
+
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
|
|
120
|
+
return res.stdout === 'true'
|
|
121
|
+
} catch {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Получить список существующих путей воркдеревьев для репозитория.
|
|
128
|
+
*/
|
|
129
|
+
export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await gitRunner(['worktree', 'list', '--porcelain'], repoDir)
|
|
132
|
+
const paths = []
|
|
133
|
+
for (const line of res.stdout.split('\n')) {
|
|
134
|
+
if (line.startsWith('worktree ')) {
|
|
135
|
+
paths.push(resolve(line.slice(9).trim()))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return paths
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Определить базовую ветку для создания воркдерева.
|
|
146
|
+
*/
|
|
147
|
+
export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
|
|
148
|
+
for (const candidate of ['origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
|
|
149
|
+
try {
|
|
150
|
+
await gitRunner(['rev-parse', '--verify', '--quiet', candidate], repoDir)
|
|
151
|
+
return candidate
|
|
152
|
+
} catch {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return 'HEAD'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Создать или переиспользовать воркдерево под задачу.
|
|
161
|
+
*/
|
|
162
|
+
export async function createTaskWorktree({
|
|
163
|
+
task,
|
|
164
|
+
repoDir,
|
|
165
|
+
config,
|
|
166
|
+
gitRunner = defaultGitRunner,
|
|
167
|
+
env,
|
|
168
|
+
cwdOf,
|
|
169
|
+
}) {
|
|
170
|
+
if (!repoDir) return undefined
|
|
171
|
+
const isGit = await isGitRepo(repoDir, gitRunner)
|
|
172
|
+
if (!isGit) return undefined
|
|
173
|
+
|
|
174
|
+
const worktreePath = resolveTaskWorktreePath({ task, config, env, cwdOf })
|
|
175
|
+
const branchName = resolveTaskBranch(task)
|
|
176
|
+
|
|
177
|
+
// Проверяем, не зарегистрировано ли уже это воркдерево
|
|
178
|
+
const existing = await listWorktrees(repoDir, gitRunner)
|
|
179
|
+
if (existing.includes(resolve(worktreePath))) {
|
|
180
|
+
return { worktreePath, branchName, reused: true }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Проверяем, существует ли уже локальная ветка с таким именем
|
|
184
|
+
let branchExists = false
|
|
185
|
+
try {
|
|
186
|
+
await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir)
|
|
187
|
+
branchExists = true
|
|
188
|
+
} catch {
|
|
189
|
+
branchExists = false
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (branchExists) {
|
|
193
|
+
await gitRunner(['worktree', 'add', worktreePath, branchName], repoDir)
|
|
194
|
+
} else {
|
|
195
|
+
const baseRef = await determineBaseRef(repoDir, gitRunner)
|
|
196
|
+
await gitRunner(['worktree', 'add', '-b', branchName, worktreePath, baseRef], repoDir)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { worktreePath, branchName, created: true }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Проверить наличие незакоммиченных изменений в воркдереве.
|
|
204
|
+
*/
|
|
205
|
+
export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
|
|
206
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
207
|
+
return { dirty: false, files: [] }
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const res = await gitRunner(['status', '--porcelain'], worktreePath)
|
|
211
|
+
const files = res.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
|
212
|
+
return { dirty: files.length > 0, files }
|
|
213
|
+
} catch (error) {
|
|
214
|
+
return { dirty: false, files: [], error: String(error?.message ?? error) }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Безопасно удалить воркдерево задачи.
|
|
220
|
+
*/
|
|
221
|
+
export async function removeTaskWorktree({
|
|
222
|
+
repoDir,
|
|
223
|
+
worktreePath,
|
|
224
|
+
branchName,
|
|
225
|
+
force = false,
|
|
226
|
+
deleteBranch = true,
|
|
227
|
+
gitRunner = defaultGitRunner,
|
|
228
|
+
}) {
|
|
229
|
+
if (!worktreePath || !existsSync(worktreePath)) {
|
|
230
|
+
return { removed: false, reason: 'not-found' }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
|
|
234
|
+
if (dirtyCheck.dirty && !force) {
|
|
235
|
+
return {
|
|
236
|
+
removed: false,
|
|
237
|
+
error: 'worktree-dirty',
|
|
238
|
+
files: dirtyCheck.files,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const removeArgs = force
|
|
243
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
244
|
+
: ['worktree', 'remove', worktreePath]
|
|
245
|
+
|
|
246
|
+
await gitRunner(removeArgs, repoDir)
|
|
247
|
+
try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune игнорирует ошибки */ }
|
|
248
|
+
|
|
249
|
+
let branchDeleted = false
|
|
250
|
+
if (deleteBranch && branchName && repoDir) {
|
|
251
|
+
try {
|
|
252
|
+
await gitRunner(['branch', '-d', branchName], repoDir)
|
|
253
|
+
branchDeleted = true
|
|
254
|
+
} catch {
|
|
255
|
+
// Игнорируем: ветка не удалена, если ещё не влита
|
|
256
|
+
branchDeleted = false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { removed: true, branchDeleted }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Зарегистрировать каталог воркдерева в DSH workspaceRegistry.
|
|
265
|
+
*/
|
|
266
|
+
export async function registerDshWorkspace({ workspaceRegistry, worktreePath, title }) {
|
|
267
|
+
if (!workspaceRegistry || typeof workspaceRegistry.create !== 'function') return undefined
|
|
268
|
+
try {
|
|
269
|
+
const entity = await workspaceRegistry.create(worktreePath, title || 'Task Workspace')
|
|
270
|
+
return entity?.id
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Дерегистрировать воркспейс из DSH workspaceRegistry.
|
|
278
|
+
*/
|
|
279
|
+
export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId }) {
|
|
280
|
+
if (!workspaceRegistry || typeof workspaceRegistry.delete !== 'function' || !workspaceId) return false
|
|
281
|
+
try {
|
|
282
|
+
return (await workspaceRegistry.delete(workspaceId)) === true
|
|
283
|
+
} catch {
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Выполнить очистку воркдерева задачи с проверкой незакоммиченных изменений.
|
|
290
|
+
*/
|
|
291
|
+
export async function cleanupTaskWorktree({
|
|
292
|
+
store,
|
|
293
|
+
task,
|
|
294
|
+
repoDir,
|
|
295
|
+
workspaceRegistry,
|
|
296
|
+
force = false,
|
|
297
|
+
deleteBranch = true,
|
|
298
|
+
gitRunner = defaultGitRunner,
|
|
299
|
+
}) {
|
|
300
|
+
const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
|
|
301
|
+
if (!current) return { error: 'task-not-found', status: 404 }
|
|
302
|
+
if (!current.worktree) return { noop: true, removed: false }
|
|
303
|
+
|
|
304
|
+
const targetRepoDir = repoDir || current.worktree
|
|
305
|
+
const result = await removeTaskWorktree({
|
|
306
|
+
repoDir: targetRepoDir,
|
|
307
|
+
worktreePath: current.worktree,
|
|
308
|
+
branchName: current.branch,
|
|
309
|
+
force,
|
|
310
|
+
deleteBranch,
|
|
311
|
+
gitRunner,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
if (result.error === 'worktree-dirty') {
|
|
315
|
+
store?.addTransition?.({
|
|
316
|
+
taskId: current.id,
|
|
317
|
+
fromCol: current.column,
|
|
318
|
+
toCol: current.column,
|
|
319
|
+
source: 'session',
|
|
320
|
+
detail: `очистка отложена: обнаружены незакоммиченные файлы: ${result.files.slice(0, 3).join(', ')}`,
|
|
321
|
+
})
|
|
322
|
+
return { error: 'worktree-dirty', files: result.files }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (workspaceRegistry && current.workspaceId) {
|
|
326
|
+
await deregisterDshWorkspace({ workspaceRegistry, workspaceId: current.workspaceId })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
store?.updateTask?.(current.id, { worktree: '', workspaceId: '' })
|
|
330
|
+
store?.addTransition?.({
|
|
331
|
+
taskId: current.id,
|
|
332
|
+
fromCol: current.column,
|
|
333
|
+
toCol: current.column,
|
|
334
|
+
source: 'session',
|
|
335
|
+
detail: 'очистка: воркдерево удалено' + (result.branchDeleted ? ', ветка удалена' : ''),
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
return { success: true, removed: true, branchDeleted: result.branchDeleted }
|
|
339
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|