@goodandready/dsh-kanban 0.2.23 → 0.2.25
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/client.js +1 -6
- package/lib/config.js +6 -1
- package/lib/import.js +27 -10
- package/lib/index.js +55 -11
- package/lib/intake.js +3 -3
- package/lib/launcher.js +61 -41
- package/lib/order.js +15 -0
- package/lib/progress-dump.js +3 -6
- package/lib/routes.js +18 -6
- package/lib/store.js +66 -22
- package/lib/sync.js +3 -2
- package/lib/workflow-bridge.js +1 -1
- package/lib/worktree.js +88 -15
- package/package.json +6 -6
package/lib/client.js
CHANGED
|
@@ -571,12 +571,7 @@ window.__ModuleLoader__.load({
|
|
|
571
571
|
|
|
572
572
|
async function api(path, options) {
|
|
573
573
|
const timeoutMs = (options && options.timeout) || 20000
|
|
574
|
-
|
|
575
|
-
try {
|
|
576
|
-
if (!options?.signal) {
|
|
577
|
-
timeoutSignal = createTimeoutSignal(timeoutMs)
|
|
578
|
-
}
|
|
579
|
-
} catch (_) {}
|
|
574
|
+
const timeoutSignal = !options?.signal ? createTimeoutSignal(timeoutMs) : undefined
|
|
580
575
|
|
|
581
576
|
const fetchOpts = Object.assign({
|
|
582
577
|
headers: { 'Content-Type': 'application/json' },
|
package/lib/config.js
CHANGED
|
@@ -138,7 +138,12 @@ export function rootOf(config, cwdOf = () => process.cwd()) {
|
|
|
138
138
|
export function withDefaults(raw) {
|
|
139
139
|
const out = {}
|
|
140
140
|
for (const [key, fallback] of Object.entries(CONFIG_DEFAULTS)) {
|
|
141
|
-
|
|
141
|
+
let value = raw?.[key]
|
|
142
|
+
if (value && typeof value === 'object' && typeof value.get === 'function') {
|
|
143
|
+
try {
|
|
144
|
+
value = value.get()
|
|
145
|
+
} catch { /* ignore */ }
|
|
146
|
+
}
|
|
142
147
|
out[key] = typeof value === typeof fallback && value !== null ? value : fallback
|
|
143
148
|
}
|
|
144
149
|
return out
|
package/lib/import.js
CHANGED
|
@@ -66,12 +66,13 @@ export function issueMilestone(issue) {
|
|
|
66
66
|
* Fields `branch`, `worktree`, `sessionId`, `model` are NOT populated here:
|
|
67
67
|
* they are assigned later when work begins. The plugin does not create branches on import.
|
|
68
68
|
*/
|
|
69
|
-
export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo, provider }) {
|
|
70
|
-
const
|
|
69
|
+
export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo, provider, forge }) {
|
|
70
|
+
const detectedForge = forge || (typeof issue?.html_url === 'string' && issue.html_url.includes('github.com') ? 'github' : 'gitea')
|
|
71
71
|
return {
|
|
72
72
|
board,
|
|
73
73
|
column,
|
|
74
|
-
|
|
74
|
+
forge: detectedForge,
|
|
75
|
+
...(provider && provider !== 'github' && provider !== 'gitea' ? { provider } : {}),
|
|
75
76
|
title: typeof issue?.title === 'string' ? issue.title : '',
|
|
76
77
|
body: typeof issue?.body === 'string' ? issue.body : '',
|
|
77
78
|
labels: Array.isArray(issue?.labels)
|
|
@@ -96,10 +97,18 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
|
|
|
96
97
|
* Compared by owner-repo-number tuple to avoid colliding with issue #1 in another repo.
|
|
97
98
|
* Existing issues are dimmed rather than hidden, as re-importing may be intentional.
|
|
98
99
|
*/
|
|
99
|
-
export function markImported(issues, existing, { owner, repo }) {
|
|
100
|
+
export function markImported(issues, existing, { owner, repo, forge }) {
|
|
101
|
+
const targetForge = forge || ''
|
|
100
102
|
const seen = new Set(
|
|
101
103
|
(existing ?? [])
|
|
102
|
-
.filter((t) =>
|
|
104
|
+
.filter((t) => {
|
|
105
|
+
if (t.owner !== owner || t.repo !== repo || typeof t.issueNumber !== 'number') return false
|
|
106
|
+
if (targetForge) {
|
|
107
|
+
const tf = t.forge || (typeof t.issueUrl === 'string' && t.issueUrl.includes('github.com') ? 'github' : 'gitea')
|
|
108
|
+
return tf === targetForge
|
|
109
|
+
}
|
|
110
|
+
return true
|
|
111
|
+
})
|
|
103
112
|
.map((t) => t.issueNumber),
|
|
104
113
|
)
|
|
105
114
|
return (issues ?? []).map((issue) => ({ ...issue, imported: seen.has(issue?.number) }))
|
|
@@ -157,7 +166,7 @@ export async function listImportable({ gitea, store, owner, repo, board = 'main'
|
|
|
157
166
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
158
167
|
const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
|
|
159
168
|
const existing = store.listTasks({ board })
|
|
160
|
-
return { issues: markImported(issues, existing, { owner, repo }) }
|
|
169
|
+
return { issues: markImported(issues, existing, { owner, repo, forge: forge || provider }) }
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
/** Create a task from an issue. */
|
|
@@ -167,9 +176,15 @@ export async function importIssue({ gitea, store, owner, repo, issueNumber, boar
|
|
|
167
176
|
if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
|
|
168
177
|
// Simple board is for non-issue tasks. Importing here would bring branches/PRs into columns unable to display them.
|
|
169
178
|
if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
|
|
170
|
-
const
|
|
179
|
+
const effectiveForge = forge || (provider === 'github' || provider === 'gitea' ? provider : undefined)
|
|
180
|
+
const effectiveProvider = (provider !== 'github' && provider !== 'gitea') ? provider : undefined
|
|
181
|
+
if (typeof store.findTaskByIssue === 'function') {
|
|
182
|
+
const existing = store.findTaskByIssue({ owner, repo, issueNumber, forge: effectiveForge })
|
|
183
|
+
if (existing) return { task: existing }
|
|
184
|
+
}
|
|
185
|
+
const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: effectiveForge || effectiveProvider })
|
|
171
186
|
if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
|
|
172
|
-
return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge:
|
|
187
|
+
return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: effectiveForge, provider: effectiveProvider })) }
|
|
173
188
|
}
|
|
174
189
|
|
|
175
190
|
/** Re-read issue and update task, preserving local state. */
|
|
@@ -222,13 +237,15 @@ export async function createProjectTask({
|
|
|
222
237
|
targetRepo = wanted
|
|
223
238
|
}
|
|
224
239
|
|
|
240
|
+
const effectiveForge = forge || (provider === 'github' || provider === 'gitea' ? provider : undefined)
|
|
241
|
+
const effectiveProvider = (provider !== 'github' && provider !== 'gitea') ? provider : undefined
|
|
225
242
|
let issue
|
|
226
243
|
try {
|
|
227
|
-
issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider:
|
|
244
|
+
issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: effectiveForge || effectiveProvider })
|
|
228
245
|
} catch (error) {
|
|
229
246
|
// Repository may have already been created; avoid deleting on issue creation failure.
|
|
230
247
|
return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
|
|
231
248
|
}
|
|
232
249
|
|
|
233
|
-
return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge:
|
|
250
|
+
return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: effectiveForge, provider: effectiveProvider })) }
|
|
234
251
|
}
|
package/lib/index.js
CHANGED
|
@@ -234,7 +234,7 @@ export function apply(ctx, config) {
|
|
|
234
234
|
if (typeof settingsSvc?.describe !== 'function') return null
|
|
235
235
|
try {
|
|
236
236
|
const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
|
|
237
|
-
if (!row || row.status !==
|
|
237
|
+
if (!row || !row.value || (row.status !== undefined && row.status !== 'ready')) return null
|
|
238
238
|
return withDefaults(row.value)
|
|
239
239
|
} catch (err) {
|
|
240
240
|
ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
|
|
@@ -268,14 +268,32 @@ export function apply(ctx, config) {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
|
+
const handleDocumentUpdated = (ns) => {
|
|
272
|
+
if (!ns || ns === SETTINGS_NAMESPACE) {
|
|
273
|
+
const served = readServedConfig()
|
|
274
|
+
if (served) {
|
|
275
|
+
effective = served
|
|
276
|
+
rearm()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const handleVolatileUpdate = (payload) => {
|
|
281
|
+
const served = readServedConfig()
|
|
282
|
+
if (served) {
|
|
283
|
+
effective = served
|
|
284
|
+
rearm()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
applyPatch(payload)
|
|
288
|
+
}
|
|
271
289
|
if (typeof ctx.effect === 'function') {
|
|
272
|
-
ctx.effect(() => ctx.on('loader/volatile-update',
|
|
290
|
+
ctx.effect(() => ctx.on('loader/volatile-update', handleVolatileUpdate))
|
|
273
291
|
ctx.effect(() => ctx.on('config', applyPatch))
|
|
274
|
-
ctx.effect(() => ctx.on('settings/document-updated',
|
|
292
|
+
ctx.effect(() => ctx.on('settings/document-updated', handleDocumentUpdated))
|
|
275
293
|
} else {
|
|
276
|
-
ctx.on('loader/volatile-update',
|
|
294
|
+
ctx.on('loader/volatile-update', handleVolatileUpdate)
|
|
277
295
|
ctx.on('config', applyPatch)
|
|
278
|
-
ctx.on('settings/document-updated',
|
|
296
|
+
ctx.on('settings/document-updated', handleDocumentUpdated)
|
|
279
297
|
}
|
|
280
298
|
}
|
|
281
299
|
|
|
@@ -1142,6 +1160,9 @@ export function apply(ctx, config) {
|
|
|
1142
1160
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
1143
1161
|
force: input?.force === true,
|
|
1144
1162
|
cleanupTaskWorktree,
|
|
1163
|
+
config: getConfig(),
|
|
1164
|
+
repoDir: rootOf(getConfig()).path,
|
|
1165
|
+
cwdOf: () => rootOf(getConfig()).path,
|
|
1145
1166
|
})
|
|
1146
1167
|
writeJson(res, resObj.status, resObj.body)
|
|
1147
1168
|
return
|
|
@@ -1209,6 +1230,9 @@ export function apply(ctx, config) {
|
|
|
1209
1230
|
}
|
|
1210
1231
|
if (req.method === 'POST' && action === 'report') {
|
|
1211
1232
|
const resObj = handleTaskReport({ store, task: store.getTask(id), input })
|
|
1233
|
+
if (resObj?.body?.task || resObj?.body?.report) {
|
|
1234
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1235
|
+
}
|
|
1212
1236
|
writeJson(res, resObj.status, resObj.body)
|
|
1213
1237
|
return
|
|
1214
1238
|
}
|
|
@@ -1219,6 +1243,9 @@ export function apply(ctx, config) {
|
|
|
1219
1243
|
}
|
|
1220
1244
|
if (req.method === 'POST' && action === 'comments') {
|
|
1221
1245
|
const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
|
|
1246
|
+
if (resObj?.body?.task || resObj?.status === 200) {
|
|
1247
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
|
|
1248
|
+
}
|
|
1222
1249
|
writeJson(res, resObj.status, resObj.body)
|
|
1223
1250
|
return
|
|
1224
1251
|
}
|
|
@@ -1310,11 +1337,19 @@ export function apply(ctx, config) {
|
|
|
1310
1337
|
return
|
|
1311
1338
|
}
|
|
1312
1339
|
if (req.method === 'POST' && action === 'archive') {
|
|
1313
|
-
|
|
1340
|
+
const resObj = setArchived({ store, id, archived: true })
|
|
1341
|
+
if (resObj?.task) {
|
|
1342
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'archived' })
|
|
1343
|
+
}
|
|
1344
|
+
reply(res, resObj)
|
|
1314
1345
|
return
|
|
1315
1346
|
}
|
|
1316
1347
|
if (req.method === 'POST' && action === 'restore') {
|
|
1317
|
-
|
|
1348
|
+
const resObj = setArchived({ store, id, archived: false })
|
|
1349
|
+
if (resObj?.task) {
|
|
1350
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'restored' })
|
|
1351
|
+
}
|
|
1352
|
+
reply(res, resObj)
|
|
1318
1353
|
return
|
|
1319
1354
|
}
|
|
1320
1355
|
if (req.method === 'POST' && action === 'queue') {
|
|
@@ -1330,11 +1365,19 @@ export function apply(ctx, config) {
|
|
|
1330
1365
|
return
|
|
1331
1366
|
}
|
|
1332
1367
|
if (req.method === 'POST' && action === 'note') {
|
|
1333
|
-
|
|
1368
|
+
const out = appendNote({ store, id, input })
|
|
1369
|
+
if (out?.task || out?.text) {
|
|
1370
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated', task: store.getTask(id) })
|
|
1371
|
+
}
|
|
1372
|
+
reply(res, out)
|
|
1334
1373
|
return
|
|
1335
1374
|
}
|
|
1336
|
-
if (req.method === 'PATCH' && action === undefined) {
|
|
1337
|
-
|
|
1375
|
+
if (req.method === 'PATCH' && (action === undefined || action === '')) {
|
|
1376
|
+
const resObj = updateTask({ store, id, input })
|
|
1377
|
+
if (resObj?.task) {
|
|
1378
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1379
|
+
}
|
|
1380
|
+
reply(res, resObj)
|
|
1338
1381
|
return
|
|
1339
1382
|
}
|
|
1340
1383
|
writeJson(res, 405, { error: 'method-not-allowed' })
|
|
@@ -1380,9 +1423,10 @@ export function apply(ctx, config) {
|
|
|
1380
1423
|
try {
|
|
1381
1424
|
const input = await readBody(req)
|
|
1382
1425
|
reply(res, await importIssue({
|
|
1383
|
-
gitea, store,
|
|
1426
|
+
gitea: git, store,
|
|
1384
1427
|
owner: input.owner, repo: input.repo, issueNumber: input.issueNumber,
|
|
1385
1428
|
board: input.board ?? 'main', column: input.column ?? 'backlog',
|
|
1429
|
+
forge: input.forge || input.provider,
|
|
1386
1430
|
}))
|
|
1387
1431
|
} catch (error) {
|
|
1388
1432
|
writeJson(res, error?.status ?? 500, { error: 'bad-request' })
|
package/lib/intake.js
CHANGED
|
@@ -41,7 +41,7 @@ export function watchedRepos({ config, owner }) {
|
|
|
41
41
|
*
|
|
42
42
|
* @returns {{take: boolean, why: string}} `why` states reason on rejection.
|
|
43
43
|
*/
|
|
44
|
-
export function shouldTake({ store, owner, repo, issue }) {
|
|
44
|
+
export function shouldTake({ store, owner, repo, issue, forge }) {
|
|
45
45
|
const number = issue?.number
|
|
46
46
|
if (typeof number !== 'number') return { take: false, why: 'missing-number' }
|
|
47
47
|
// Pull request in Gitea is also an issue, but not a standalone board card:
|
|
@@ -50,10 +50,10 @@ export function shouldTake({ store, owner, repo, issue }) {
|
|
|
50
50
|
return { take: false, why: 'pull-request-ignored' }
|
|
51
51
|
}
|
|
52
52
|
if (issue?.state === 'closed') return { take: false, why: 'issue-closed' }
|
|
53
|
-
if (store.findTaskByIssue({ owner, repo, issueNumber: number }) !== undefined) {
|
|
53
|
+
if (store.findTaskByIssue({ owner, repo, issueNumber: number, forge }) !== undefined) {
|
|
54
54
|
return { take: false, why: 'already-exists' }
|
|
55
55
|
}
|
|
56
|
-
if (store.isDismissed({ owner, repo, issueNumber: number })) {
|
|
56
|
+
if (store.isDismissed({ owner, repo, issueNumber: number, forge })) {
|
|
57
57
|
return { take: false, why: 'tombstone-ignored' }
|
|
58
58
|
}
|
|
59
59
|
return { take: true, why: '' }
|
package/lib/launcher.js
CHANGED
|
@@ -163,7 +163,7 @@ export async function obtainAgent({
|
|
|
163
163
|
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
164
164
|
reasoningEffort,
|
|
165
165
|
}) {
|
|
166
|
-
const chosenPerm = permission || task?.permission ||
|
|
166
|
+
const chosenPerm = permission || task?.permission || config?.sessionDefaultPermission || ""
|
|
167
167
|
const effort = reasoningEffort || task?.reasoningEffort || undefined
|
|
168
168
|
const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }
|
|
169
169
|
|
|
@@ -210,7 +210,17 @@ export async function obtainAgent({
|
|
|
210
210
|
let workspaceId = task?.workspaceId || undefined
|
|
211
211
|
let worktreeCreated = false
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
let isGit = false
|
|
214
|
+
try {
|
|
215
|
+
isGit = await isGitRepo(cwd, gitRunner)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (config?.worktreeIsolation !== false) {
|
|
218
|
+
const error = new Error(`worktree-creation-failed: git probe failed: ${err?.message || err}`)
|
|
219
|
+
error.key = 'worktree-creation-failed'
|
|
220
|
+
error.status = 500
|
|
221
|
+
throw error
|
|
222
|
+
}
|
|
223
|
+
}
|
|
214
224
|
if (config?.worktreeIsolation !== false && isGit) {
|
|
215
225
|
let created
|
|
216
226
|
try {
|
|
@@ -331,6 +341,9 @@ export async function runTask({
|
|
|
331
341
|
}) {
|
|
332
342
|
const effort = reasoningEffort || task?.reasoningEffort || undefined
|
|
333
343
|
|
|
344
|
+
// Gate check must reject unconfirmed elevated tasks before queueing or state changes (#328)
|
|
345
|
+
verifyPermissionGate({ task, permission, config })
|
|
346
|
+
|
|
334
347
|
// Concurrency limit check (#214, #330)
|
|
335
348
|
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
336
349
|
if (maxConcurrent > 0) {
|
|
@@ -360,8 +373,6 @@ export async function runTask({
|
|
|
360
373
|
}
|
|
361
374
|
}
|
|
362
375
|
|
|
363
|
-
verifyPermissionGate({ task, permission, config })
|
|
364
|
-
|
|
365
376
|
reserveSessionSlot(task.id)
|
|
366
377
|
let taken
|
|
367
378
|
try {
|
|
@@ -370,9 +381,6 @@ export async function runTask({
|
|
|
370
381
|
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
371
382
|
reasoningEffort: effort,
|
|
372
383
|
})
|
|
373
|
-
} finally {
|
|
374
|
-
releaseSessionSlot(task.id)
|
|
375
|
-
}
|
|
376
384
|
|
|
377
385
|
if (task.queuedAt > 0 || task.queuedRequest) {
|
|
378
386
|
store.updateTask(task.id, { queuedAt: 0, queuedRequest: '' })
|
|
@@ -455,6 +463,9 @@ export async function runTask({
|
|
|
455
463
|
}
|
|
456
464
|
|
|
457
465
|
return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
|
|
466
|
+
} finally {
|
|
467
|
+
releaseSessionSlot(task.id)
|
|
468
|
+
}
|
|
458
469
|
}
|
|
459
470
|
|
|
460
471
|
/** Build prompt text: custom user message if provided, otherwise standard template. */
|
|
@@ -552,9 +563,6 @@ export async function runBatch({
|
|
|
552
563
|
agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
|
|
553
564
|
agentPreset, permission, permissions,
|
|
554
565
|
})
|
|
555
|
-
} finally {
|
|
556
|
-
releaseSessionSlot(list[0].id)
|
|
557
|
-
}
|
|
558
566
|
|
|
559
567
|
await taken.agent.whenIdle()
|
|
560
568
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
@@ -598,6 +606,9 @@ export async function runBatch({
|
|
|
598
606
|
})
|
|
599
607
|
|
|
600
608
|
return { sessionId: taken.sessionId, mode: taken.mode, started: started.length, tasks: started }
|
|
609
|
+
} finally {
|
|
610
|
+
releaseSessionSlot(list[0].id)
|
|
611
|
+
}
|
|
601
612
|
}
|
|
602
613
|
|
|
603
614
|
/**
|
|
@@ -639,7 +650,7 @@ export function unqueueTask({ store, task }) {
|
|
|
639
650
|
if (typeof task.queuedAt !== 'number' || task.queuedAt <= 0) {
|
|
640
651
|
return { error: 'task-not-queued', status: 400 }
|
|
641
652
|
}
|
|
642
|
-
store.updateTask(task.id, { queuedAt: 0, sessionId: null })
|
|
653
|
+
store.updateTask(task.id, { queuedAt: 0, sessionId: null, queuedRequest: '' })
|
|
643
654
|
store.addTransition({
|
|
644
655
|
taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
|
|
645
656
|
detail: 'removed from queue; message already sent to agent',
|
|
@@ -652,7 +663,12 @@ export function unqueueTask({ store, task }) {
|
|
|
652
663
|
*/
|
|
653
664
|
export function countActiveSessions({ store, agents }) {
|
|
654
665
|
const sessions = liveSessions({ store, agents })
|
|
655
|
-
const activeCount = sessions.filter((s) =>
|
|
666
|
+
const activeCount = sessions.filter((s) =>
|
|
667
|
+
s.status === 'running' ||
|
|
668
|
+
s.status === 'busy' ||
|
|
669
|
+
s.status === 'thinking' ||
|
|
670
|
+
s.tasks.some((t) => t.workflowActive)
|
|
671
|
+
).length
|
|
656
672
|
return activeCount + activeReservations.size
|
|
657
673
|
}
|
|
658
674
|
|
|
@@ -679,10 +695,12 @@ export function liveSessions({ store, agents }) {
|
|
|
679
695
|
if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
|
|
680
696
|
continue
|
|
681
697
|
}
|
|
698
|
+
const isWorkflowActive = task.workflowStatus === 'running' || (typeof task.workflowRunId === 'string' && task.workflowRunId !== '' && task.workflowStatus !== 'completed' && task.workflowStatus !== 'failed' && task.workflowStatus !== 'cancelled')
|
|
682
699
|
if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
|
|
683
700
|
byId.get(id).tasks.push({
|
|
684
701
|
id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
|
|
685
702
|
queued: typeof task.queuedAt === 'number' && task.queuedAt > 0,
|
|
703
|
+
workflowActive: isWorkflowActive,
|
|
686
704
|
})
|
|
687
705
|
}
|
|
688
706
|
return [...byId.values()]
|
|
@@ -705,34 +723,36 @@ export async function dispatchNextQueuedTask({
|
|
|
705
723
|
const queued = typeof store.listQueuedTasks === 'function' ? store.listQueuedTasks() : []
|
|
706
724
|
if (queued.length === 0) return null
|
|
707
725
|
|
|
708
|
-
const nextTask
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
726
|
+
for (const nextTask of queued) {
|
|
727
|
+
let queuedParams = {}
|
|
728
|
+
if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
|
|
729
|
+
try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
return await runTask({
|
|
733
|
+
agents,
|
|
734
|
+
store,
|
|
735
|
+
task: nextTask,
|
|
736
|
+
config,
|
|
737
|
+
provider: queuedParams.provider || nextTask.provider || undefined,
|
|
738
|
+
model: queuedParams.model || nextTask.model || undefined,
|
|
739
|
+
permission: queuedParams.permission || nextTask.permission || undefined,
|
|
740
|
+
reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
|
|
741
|
+
text: queuedParams.text || undefined,
|
|
742
|
+
agentPreset: queuedParams.agentPreset || agentPreset,
|
|
743
|
+
mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
|
|
744
|
+
createMessage: createMessage || ((msg) => msg),
|
|
745
|
+
cwdOf,
|
|
746
|
+
logger,
|
|
747
|
+
permissions,
|
|
748
|
+
workspaceRegistry,
|
|
749
|
+
claimAs: claimAs || 'queue-dispatcher',
|
|
750
|
+
gitRunner,
|
|
751
|
+
})
|
|
752
|
+
} catch (err) {
|
|
753
|
+
logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
|
|
754
|
+
unqueueTask({ store, task: nextTask })
|
|
755
|
+
}
|
|
737
756
|
}
|
|
757
|
+
return null
|
|
738
758
|
}
|
package/lib/order.js
CHANGED
|
@@ -30,4 +30,19 @@ export function keyBetween(a, b) {
|
|
|
30
30
|
return generateKeyBetween(a ?? null, b ?? null)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export function isValidOrderKey(key) {
|
|
34
|
+
if (typeof key !== 'string' || !key || /[^0-9a-zA-Z]/.test(key)) return false
|
|
35
|
+
try {
|
|
36
|
+
generateKeyBetween(null, key)
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
try {
|
|
40
|
+
generateKeyBetween(key, null)
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
export { generateKeyBetween, generateNKeysBetween }
|
package/lib/progress-dump.js
CHANGED
|
@@ -62,21 +62,18 @@ export function parseProgressDump(text) {
|
|
|
62
62
|
const line = rawLine.trim()
|
|
63
63
|
const lower = line.toLowerCase()
|
|
64
64
|
|
|
65
|
-
if (lower.startsWith('goal:')
|
|
65
|
+
if (lower.startsWith('goal:')) {
|
|
66
66
|
currentKey = 'goal'
|
|
67
67
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
68
68
|
if (val) sections.goal.push(val)
|
|
69
|
-
} else if (lower.startsWith('progress:') || lower.startsWith('done:')
|
|
69
|
+
} else if (lower.startsWith('progress:') || lower.startsWith('done:')) {
|
|
70
70
|
currentKey = 'progress'
|
|
71
71
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
72
72
|
if (val) sections.progress.push(val)
|
|
73
73
|
} else if (
|
|
74
74
|
lower.startsWith('next:') ||
|
|
75
75
|
lower.startsWith('next steps:') ||
|
|
76
|
-
lower.startsWith('next step:')
|
|
77
|
-
lower.startsWith('следующие шаги:') ||
|
|
78
|
-
lower.startsWith('шаги:') ||
|
|
79
|
-
lower.startsWith('дальше:')
|
|
76
|
+
lower.startsWith('next step:')
|
|
80
77
|
) {
|
|
81
78
|
currentKey = 'next'
|
|
82
79
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
package/lib/routes.js
CHANGED
|
@@ -238,9 +238,7 @@ export function updateTask({ store, id, input }) {
|
|
|
238
238
|
patch.permissionConfirmedTier = ''
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
|
|
242
|
-
patch.permissionConfirmedTier = input.permissionConfirmedTier
|
|
243
|
-
}
|
|
241
|
+
|
|
244
242
|
if (typeof input?.reasoningEffort === 'string') {
|
|
245
243
|
const val = input.reasoningEffort.trim().toLowerCase()
|
|
246
244
|
if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
|
|
@@ -414,7 +412,13 @@ export function importBoard({ store, input }) {
|
|
|
414
412
|
const doImport = () => {
|
|
415
413
|
for (const board of Array.isArray(input.boards) ? input.boards : []) {
|
|
416
414
|
if (typeof board?.id !== 'string' || board.id === '') continue
|
|
417
|
-
store.createBoard({
|
|
415
|
+
store.createBoard({
|
|
416
|
+
id: board.id,
|
|
417
|
+
title: board.title,
|
|
418
|
+
kind: board.kind,
|
|
419
|
+
position: board.position,
|
|
420
|
+
createdAt: board.createdAt,
|
|
421
|
+
})
|
|
418
422
|
}
|
|
419
423
|
|
|
420
424
|
for (const task of input.tasks) {
|
|
@@ -524,15 +528,23 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
524
528
|
/**
|
|
525
529
|
* Clean up task worktree.
|
|
526
530
|
*/
|
|
527
|
-
export async function handleCleanupWorktree({
|
|
531
|
+
export async function handleCleanupWorktree({
|
|
532
|
+
store, task, workspaceRegistry, force, cleanupTaskWorktree,
|
|
533
|
+
config, repoDir, cwdOf, env,
|
|
534
|
+
}) {
|
|
528
535
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
529
536
|
const out = await cleanupTaskWorktree({
|
|
530
537
|
store,
|
|
531
538
|
task,
|
|
532
539
|
workspaceRegistry,
|
|
533
540
|
force: force === true,
|
|
541
|
+
config,
|
|
542
|
+
repoDir,
|
|
543
|
+
cwdOf,
|
|
544
|
+
env,
|
|
534
545
|
})
|
|
535
|
-
|
|
546
|
+
const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
|
|
547
|
+
return { status, body: out }
|
|
536
548
|
}
|
|
537
549
|
|
|
538
550
|
/**
|
package/lib/store.js
CHANGED
|
@@ -13,7 +13,7 @@ import { DatabaseSync } from 'node:sqlite'
|
|
|
13
13
|
import { randomUUID } from 'node:crypto'
|
|
14
14
|
import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync, chmodSync, existsSync } from 'node:fs'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
|
-
import { keyBetween, generateNKeysBetween } from './order.js'
|
|
16
|
+
import { keyBetween, generateNKeysBetween, isValidOrderKey } from './order.js'
|
|
17
17
|
import { DEFAULT_BOARDS, normalizeKind } from './config.js'
|
|
18
18
|
|
|
19
19
|
const SCHEMA = `
|
|
@@ -73,6 +73,9 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
73
73
|
isEpic INTEGER NOT NULL DEFAULT 0,
|
|
74
74
|
workflowRunId TEXT NOT NULL DEFAULT '',
|
|
75
75
|
workflowStatus TEXT NOT NULL DEFAULT '',
|
|
76
|
+
forge TEXT NOT NULL DEFAULT '',
|
|
77
|
+
permissionConfirmedTier TEXT NOT NULL DEFAULT '',
|
|
78
|
+
queuedRequest TEXT NOT NULL DEFAULT '',
|
|
76
79
|
updatedAt INTEGER NOT NULL
|
|
77
80
|
);
|
|
78
81
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -91,8 +94,9 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
91
94
|
owner TEXT NOT NULL,
|
|
92
95
|
repo TEXT NOT NULL,
|
|
93
96
|
issueNumber INTEGER NOT NULL,
|
|
97
|
+
forge TEXT NOT NULL DEFAULT '',
|
|
94
98
|
at INTEGER NOT NULL,
|
|
95
|
-
PRIMARY KEY (owner, repo, issueNumber)
|
|
99
|
+
PRIMARY KEY (owner, repo, issueNumber, forge)
|
|
96
100
|
);
|
|
97
101
|
`
|
|
98
102
|
|
|
@@ -103,7 +107,7 @@ const WRITABLE = [
|
|
|
103
107
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
104
108
|
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
105
109
|
'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
|
|
106
|
-
'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge',
|
|
110
|
+
'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
|
|
107
111
|
]
|
|
108
112
|
|
|
109
113
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
@@ -244,6 +248,7 @@ export function openStore({ dir }) {
|
|
|
244
248
|
['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
245
249
|
['forge', "TEXT NOT NULL DEFAULT ''"],
|
|
246
250
|
['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
|
|
251
|
+
['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
|
|
247
252
|
]) {
|
|
248
253
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
249
254
|
}
|
|
@@ -283,12 +288,35 @@ export function openStore({ dir }) {
|
|
|
283
288
|
}
|
|
284
289
|
}
|
|
285
290
|
}
|
|
291
|
+
const boardRows = db.prepare('SELECT id, position FROM boards ORDER BY position ASC, id ASC').all()
|
|
292
|
+
if (boardRows.length > 0) {
|
|
293
|
+
const updateBoardPos = db.prepare('UPDATE boards SET position = ? WHERE id = ?')
|
|
294
|
+
const newBoardKeys = generateNKeysBetween(null, null, boardRows.length)
|
|
295
|
+
for (let i = 0; i < boardRows.length; i++) {
|
|
296
|
+
updateBoardPos.run(newBoardKeys[i], boardRows[i].id)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
286
299
|
db.prepare("INSERT OR REPLACE INTO meta (key, val) VALUES ('fractional_indexing_migrated', '1')").run()
|
|
287
300
|
db.exec('COMMIT')
|
|
288
301
|
} catch (e) {
|
|
289
302
|
try { db.exec('ROLLBACK') } catch { /* ignore */ }
|
|
290
303
|
}
|
|
291
304
|
}
|
|
305
|
+
// Ensure board order keys are valid, even if fractional_indexing_migrated marker already exists (#345)
|
|
306
|
+
const boardRows = db.prepare('SELECT id, position FROM boards ORDER BY position ASC, id ASC').all()
|
|
307
|
+
if (boardRows.length > 0 && boardRows.some((b) => !isValidOrderKey(b.position))) {
|
|
308
|
+
try {
|
|
309
|
+
db.exec('BEGIN TRANSACTION')
|
|
310
|
+
const updateBoardPos = db.prepare('UPDATE boards SET position = ? WHERE id = ?')
|
|
311
|
+
const newBoardKeys = generateNKeysBetween(null, null, boardRows.length)
|
|
312
|
+
for (let i = 0; i < boardRows.length; i++) {
|
|
313
|
+
updateBoardPos.run(newBoardKeys[i], boardRows[i].id)
|
|
314
|
+
}
|
|
315
|
+
db.exec('COMMIT')
|
|
316
|
+
} catch {
|
|
317
|
+
try { db.exec('ROLLBACK') } catch { /* ignore */ }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
292
320
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
293
321
|
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
294
322
|
|
|
@@ -303,16 +331,16 @@ export function openStore({ dir }) {
|
|
|
303
331
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
304
332
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
|
|
305
333
|
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
|
|
306
|
-
workflowRunId, workflowStatus)
|
|
307
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
334
|
+
workflowRunId, workflowStatus, forge, queuedRequest)
|
|
335
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
308
336
|
restoreTask: db.prepare(`INSERT INTO tasks
|
|
309
337
|
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
|
|
310
338
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
311
339
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
312
340
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, permissionConfirmedTier, runs,
|
|
313
341
|
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
|
|
314
|
-
workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge)
|
|
315
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
342
|
+
workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge, queuedRequest)
|
|
343
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
316
344
|
ON CONFLICT(id) DO UPDATE SET
|
|
317
345
|
board=excluded.board, col=excluded.col, position=excluded.position, title=excluded.title,
|
|
318
346
|
body=excluded.body, owner=excluded.owner, repo=excluded.repo, issueNumber=excluded.issueNumber,
|
|
@@ -330,7 +358,8 @@ export function openStore({ dir }) {
|
|
|
330
358
|
rejectReason=excluded.rejectReason, rejectedAt=excluded.rejectedAt,
|
|
331
359
|
reasoningEffort=excluded.reasoningEffort, parentId=excluded.parentId, isEpic=excluded.isEpic,
|
|
332
360
|
workflowRunId=excluded.workflowRunId, workflowStatus=excluded.workflowStatus,
|
|
333
|
-
archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge
|
|
361
|
+
archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge,
|
|
362
|
+
queuedRequest=excluded.queuedRequest`),
|
|
334
363
|
restoreTransition: db.prepare(`INSERT INTO transitions
|
|
335
364
|
(taskId, fromCol, toCol, source, detail, at)
|
|
336
365
|
VALUES (?, ?, ?, ?, ?, ?)`),
|
|
@@ -344,7 +373,7 @@ export function openStore({ dir }) {
|
|
|
344
373
|
archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
|
|
345
374
|
archivable: db.prepare(
|
|
346
375
|
"SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
|
|
347
|
-
dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
|
|
376
|
+
dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, forge, at) VALUES (?, ?, ?, ?, ?)'),
|
|
348
377
|
isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
349
378
|
isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
|
|
350
379
|
byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
@@ -365,12 +394,19 @@ export function openStore({ dir }) {
|
|
|
365
394
|
"SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
|
|
366
395
|
}
|
|
367
396
|
|
|
368
|
-
function ensureBoard(id, title, kind) {
|
|
397
|
+
function ensureBoard(id, title, kind, position, createdAt) {
|
|
369
398
|
const found = q.boardById.get(id)
|
|
370
|
-
if (found !== undefined)
|
|
399
|
+
if (found !== undefined) {
|
|
400
|
+
if (position !== undefined || createdAt !== undefined || title !== undefined || kind !== undefined) {
|
|
401
|
+
db.prepare('UPDATE boards SET title = ?, kind = ?, position = ?, createdAt = ? WHERE id = ?')
|
|
402
|
+
.run(title ?? found.title, normalizeKind(kind ?? found.kind), position ?? found.position, createdAt ?? found.createdAt, id)
|
|
403
|
+
}
|
|
404
|
+
return q.boardById.get(id)
|
|
405
|
+
}
|
|
371
406
|
const last = q.lastBoard.get()
|
|
372
|
-
const
|
|
373
|
-
|
|
407
|
+
const pos = isValidOrderKey(position) ? position : keyBetween(last?.position, undefined)
|
|
408
|
+
const created = typeof createdAt === 'number' && createdAt > 0 ? createdAt : Date.now()
|
|
409
|
+
q.insertBoard.run(id, title, normalizeKind(kind), pos, created)
|
|
374
410
|
return q.boardById.get(id)
|
|
375
411
|
}
|
|
376
412
|
|
|
@@ -440,8 +476,8 @@ export function openStore({ dir }) {
|
|
|
440
476
|
return q.boards.all()
|
|
441
477
|
},
|
|
442
478
|
|
|
443
|
-
createBoard({ id, title, kind }) {
|
|
444
|
-
return ensureBoard(id, title ?? id, kind)
|
|
479
|
+
createBoard({ id, title, kind, position, createdAt }) {
|
|
480
|
+
return ensureBoard(id, title ?? id, kind, position, createdAt)
|
|
445
481
|
},
|
|
446
482
|
|
|
447
483
|
/** Board kind. Unrecognized kinds default to 'project'. */
|
|
@@ -529,6 +565,8 @@ export function openStore({ dir }) {
|
|
|
529
565
|
input.isEpic ? 1 : 0,
|
|
530
566
|
input.workflowRunId ?? '',
|
|
531
567
|
input.workflowStatus ?? '',
|
|
568
|
+
input.forge ?? '',
|
|
569
|
+
input.queuedRequest ?? '',
|
|
532
570
|
)
|
|
533
571
|
return rowToTask(q.taskById.get(id))
|
|
534
572
|
},
|
|
@@ -631,12 +669,17 @@ export function openStore({ dir }) {
|
|
|
631
669
|
const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
|
|
632
670
|
const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
|
|
633
671
|
const comments = JSON.stringify(Array.isArray(task.comments) ? task.comments : [])
|
|
634
|
-
const forge =
|
|
672
|
+
const forge = typeof task.forge === 'string'
|
|
673
|
+
? task.forge
|
|
674
|
+
: (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : (task.owner && task.repo && task.issueNumber ? 'gitea' : ''))
|
|
675
|
+
const targetBoard = task.board || 'main'
|
|
676
|
+
const targetCol = task.column || task.col || 'backlog'
|
|
677
|
+
const position = isValidOrderKey(task.position) ? task.position : positionFor(targetBoard, targetCol, undefined, undefined)
|
|
635
678
|
q.restoreTask.run(
|
|
636
679
|
task.id,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
680
|
+
targetBoard,
|
|
681
|
+
targetCol,
|
|
682
|
+
position,
|
|
640
683
|
task.title || '',
|
|
641
684
|
task.body || '',
|
|
642
685
|
task.owner ?? null,
|
|
@@ -650,7 +693,7 @@ export function openStore({ dir }) {
|
|
|
650
693
|
task.workspaceId || '',
|
|
651
694
|
task.sessionId ?? null,
|
|
652
695
|
task.model ?? null,
|
|
653
|
-
task.provider
|
|
696
|
+
task.provider !== undefined ? task.provider : null,
|
|
654
697
|
task.waiting ? 1 : 0,
|
|
655
698
|
task.syncedAt || 0,
|
|
656
699
|
colors,
|
|
@@ -684,6 +727,7 @@ export function openStore({ dir }) {
|
|
|
684
727
|
task.queuedAt || 0,
|
|
685
728
|
task.plan || '',
|
|
686
729
|
forge,
|
|
730
|
+
task.queuedRequest || '',
|
|
687
731
|
)
|
|
688
732
|
return rowToTask(q.taskById.get(task.id))
|
|
689
733
|
},
|
|
@@ -755,10 +799,10 @@ export function openStore({ dir }) {
|
|
|
755
799
|
},
|
|
756
800
|
|
|
757
801
|
deleteTask(id) {
|
|
758
|
-
// Record deleted
|
|
802
|
+
// Record deleted tasks as dismissed to prevent the intake loop from re-importing them.
|
|
759
803
|
const task = q.taskById.get(id)
|
|
760
804
|
if (task?.owner && task?.repo && typeof task.issueNumber === 'number') {
|
|
761
|
-
q.dismiss.run(task.owner, task.repo, task.issueNumber, Date.now())
|
|
805
|
+
q.dismiss.run(task.owner, task.repo, task.issueNumber, task.forge || '', Date.now())
|
|
762
806
|
}
|
|
763
807
|
q.deleteTask.run(id)
|
|
764
808
|
},
|
package/lib/sync.js
CHANGED
|
@@ -40,9 +40,10 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
|
|
|
40
40
|
* would lead to erroneous column transitions. If reading fails, the repository is skipped.
|
|
41
41
|
*/
|
|
42
42
|
async function readRepo({ gitea, owner, repo, forge }) {
|
|
43
|
+
const provider = forge || undefined
|
|
43
44
|
const [pulls, branches] = await Promise.all([
|
|
44
|
-
gitea.listPulls({ owner, repo, state: 'all' }),
|
|
45
|
-
gitea.listBranches({ owner, repo }),
|
|
45
|
+
gitea.listPulls({ owner, repo, state: 'all', provider }),
|
|
46
|
+
gitea.listBranches({ owner, repo, provider }),
|
|
46
47
|
])
|
|
47
48
|
return { pulls, branches }
|
|
48
49
|
}
|
package/lib/workflow-bridge.js
CHANGED
|
@@ -489,7 +489,7 @@ export class WorkflowBridge {
|
|
|
489
489
|
repo: task.repo,
|
|
490
490
|
index: task.issueNumber,
|
|
491
491
|
body: commentBody,
|
|
492
|
-
provider: task.
|
|
492
|
+
provider: task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea'),
|
|
493
493
|
})
|
|
494
494
|
} catch (err) {
|
|
495
495
|
this.logger?.warn?.(`dsh-kanban: failed to comment on issue ${task.repo}#${task.issueNumber}: ${err.message}`)
|
package/lib/worktree.js
CHANGED
|
@@ -28,7 +28,7 @@ export async function defaultGitRunner(args, cwd) {
|
|
|
28
28
|
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
29
29
|
cwd,
|
|
30
30
|
maxBuffer: 10 * 1024 * 1024,
|
|
31
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
31
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
|
|
32
32
|
})
|
|
33
33
|
return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0, exitCode: 0 }
|
|
34
34
|
} catch (err) {
|
|
@@ -136,8 +136,18 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
|
136
136
|
try {
|
|
137
137
|
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], repoDir)
|
|
138
138
|
return res.stdout?.trim() === 'true'
|
|
139
|
-
} catch {
|
|
140
|
-
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const msg = String(err?.stderr || err?.message || '').toLowerCase()
|
|
141
|
+
if (
|
|
142
|
+
msg.includes('not a git repo') ||
|
|
143
|
+
msg.includes('not a git repository') ||
|
|
144
|
+
msg.includes('не найден git репозиторий') ||
|
|
145
|
+
msg.includes('не является git-репозиторием') ||
|
|
146
|
+
(err?.code === 'ENOENT' && repoDir && !existsSync(repoDir))
|
|
147
|
+
) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
throw err
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
153
|
|
|
@@ -371,17 +381,58 @@ export async function cleanupTaskWorktree({
|
|
|
371
381
|
if (!current.worktree) return { noop: true, removed: false }
|
|
372
382
|
|
|
373
383
|
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
384
|
+
const customRoot = typeof config?.worktreeRoot === 'string' && config.worktreeRoot.trim() !== ''
|
|
385
|
+
? root
|
|
386
|
+
: undefined
|
|
374
387
|
const candidateRoots = [
|
|
375
388
|
root,
|
|
376
389
|
repoDir,
|
|
377
|
-
typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
|
|
378
|
-
process.cwd(),
|
|
390
|
+
typeof cwdOf === 'function' ? cwdOf() : (!customRoot && !repoDir ? process.cwd() : undefined),
|
|
379
391
|
].filter(Boolean)
|
|
380
|
-
const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
|
|
392
|
+
const isFenced = candidateRoots.length === 0 || candidateRoots.some((c) => isPathFenced(current.worktree, c))
|
|
381
393
|
if (!isFenced) {
|
|
382
394
|
return { error: 'unfenced-worktree-path', status: 403 }
|
|
383
395
|
}
|
|
384
396
|
|
|
397
|
+
// 1. Verify worktree cleanliness (force must not bypass dirty worktree ban #341)
|
|
398
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: current.worktree, gitRunner })
|
|
399
|
+
if (dirtyCheck.error) {
|
|
400
|
+
return { error: 'worktree-status-error', detail: dirtyCheck.error }
|
|
401
|
+
}
|
|
402
|
+
if (dirtyCheck.dirty) {
|
|
403
|
+
store?.addTransition?.({
|
|
404
|
+
taskId: current.id,
|
|
405
|
+
fromCol: current.column,
|
|
406
|
+
toCol: current.column,
|
|
407
|
+
source: 'session',
|
|
408
|
+
detail: `cleanup deferred: uncommitted files detected: ${dirtyCheck.files.slice(0, 3).join(', ')}`,
|
|
409
|
+
})
|
|
410
|
+
return { error: 'worktree-dirty', files: dirtyCheck.files }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 2. Ownership fencing: ensure worktree is registered in repository before removal (#315)
|
|
414
|
+
let baseRepo = repoDir
|
|
415
|
+
if (!baseRepo && current?.repo) {
|
|
416
|
+
const root = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
|
|
417
|
+
? config.defaultProjectRoot.trim()
|
|
418
|
+
: (typeof cwdOf === 'function' ? cwdOf() : process.cwd())
|
|
419
|
+
baseRepo = resolve(join(root, current.repo))
|
|
420
|
+
}
|
|
421
|
+
if (!baseRepo) {
|
|
422
|
+
const rev = await gitRunner(['rev-parse', '--git-common-dir'], current.worktree)
|
|
423
|
+
if ((rev?.exitCode ?? rev?.code ?? 0) === 0 && rev?.stdout?.trim()) {
|
|
424
|
+
const cd = rev.stdout.trim()
|
|
425
|
+
baseRepo = cd.endsWith('/.git') ? cd.slice(0, -5) : cd
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (baseRepo) {
|
|
429
|
+
const registered = await listWorktrees(baseRepo, gitRunner)
|
|
430
|
+
const norm = resolve(current.worktree)
|
|
431
|
+
if (!registered.some((p) => resolve(p) === norm)) {
|
|
432
|
+
return { error: 'worktree-not-registered', status: 403 }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
385
436
|
const targetRepoDir = repoDir || current.worktree
|
|
386
437
|
const result = await removeTaskWorktree({
|
|
387
438
|
repoDir: targetRepoDir,
|
|
@@ -392,15 +443,22 @@ export async function cleanupTaskWorktree({
|
|
|
392
443
|
gitRunner,
|
|
393
444
|
})
|
|
394
445
|
|
|
395
|
-
if (result.error
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
446
|
+
if (result.error) {
|
|
447
|
+
if (result.error === 'worktree-dirty') {
|
|
448
|
+
store?.addTransition?.({
|
|
449
|
+
taskId: current.id,
|
|
450
|
+
fromCol: current.column,
|
|
451
|
+
toCol: current.column,
|
|
452
|
+
source: 'session',
|
|
453
|
+
detail: `cleanup deferred: uncommitted files detected: ${result.files.slice(0, 3).join(', ')}`,
|
|
454
|
+
})
|
|
455
|
+
return { error: 'worktree-dirty', files: result.files }
|
|
456
|
+
}
|
|
457
|
+
return { success: false, removed: false, error: result.error, detail: result.detail }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!result.removed) {
|
|
461
|
+
return { success: false, removed: false, reason: result.reason }
|
|
404
462
|
}
|
|
405
463
|
|
|
406
464
|
if (workspaceRegistry && current.workspaceId) {
|
|
@@ -548,6 +606,21 @@ export async function mergeTaskWorktree({
|
|
|
548
606
|
return { success: false, merged: false, error: 'main-repo-not-found' }
|
|
549
607
|
}
|
|
550
608
|
|
|
609
|
+
// Repository ownership fence: main repository name must match task.repo (#315)
|
|
610
|
+
if (task.repo) {
|
|
611
|
+
const expectedRepo = task.repo.trim()
|
|
612
|
+
const baseName = basename(mainRepoDir)
|
|
613
|
+
if (baseName !== expectedRepo && !mainRepoDir.endsWith('/' + expectedRepo) && !mainRepoDir.endsWith(sep + expectedRepo)) {
|
|
614
|
+
return { success: false, merged: false, error: 'foreign-repository-mismatch' }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const registered = await listWorktrees(mainRepoDir, gitRunner)
|
|
619
|
+
const normTaskWorktree = resolve(task.worktree)
|
|
620
|
+
if (!registered.some((p) => resolve(p) === normTaskWorktree)) {
|
|
621
|
+
return { success: false, merged: false, error: 'worktree-not-registered' }
|
|
622
|
+
}
|
|
623
|
+
|
|
551
624
|
// 3. Resolve target merge branch
|
|
552
625
|
let target = targetBranch
|
|
553
626
|
if (target && !isSafeBranchName(target)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -18,10 +18,6 @@
|
|
|
18
18
|
"README.ru.md",
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"test": "node --test test/*.test.mjs",
|
|
23
|
-
"compat": "node tools/compat.mjs"
|
|
24
|
-
},
|
|
25
21
|
"keywords": [
|
|
26
22
|
"dsh",
|
|
27
23
|
"dsh-plugin",
|
|
@@ -93,5 +89,9 @@
|
|
|
93
89
|
"@deepseek-ai/dsh-tools": {
|
|
94
90
|
"optional": true
|
|
95
91
|
}
|
|
92
|
+
},
|
|
93
|
+
"scripts": {
|
|
94
|
+
"test": "node --test test/*.test.mjs",
|
|
95
|
+
"compat": "node tools/compat.mjs"
|
|
96
96
|
}
|
|
97
|
-
}
|
|
97
|
+
}
|