@goodandready/dsh-kanban 0.2.20 → 0.2.22
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/board-tool.js +8 -5
- package/lib/config.js +1 -1
- package/lib/events.js +16 -4
- package/lib/fractional-indexing.js +582 -0
- package/lib/git-provider.js +11 -2
- package/lib/gitea.js +8 -1
- package/lib/github.js +22 -6
- package/lib/import.js +11 -10
- package/lib/index.js +98 -16
- package/lib/launcher.js +164 -53
- package/lib/order.js +20 -50
- package/lib/outbound.js +29 -12
- package/lib/progress-dump.js +16 -11
- package/lib/routes.js +47 -25
- package/lib/store.js +165 -6
- package/lib/sync.js +6 -5
- package/lib/transitions.js +8 -4
- package/lib/workflow-bridge.js +61 -31
- package/lib/worktree.js +66 -12
- package/package.json +1 -1
package/lib/launcher.js
CHANGED
|
@@ -12,11 +12,38 @@
|
|
|
12
12
|
|
|
13
13
|
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
14
14
|
import { randomUUID } from 'node:crypto'
|
|
15
|
-
import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
|
|
15
|
+
import { createTaskWorktree, registerDshWorkspace, isGitRepo } from './worktree.js'
|
|
16
16
|
import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
|
|
17
17
|
|
|
18
18
|
export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
|
|
19
19
|
|
|
20
|
+
const activeReservations = new Set()
|
|
21
|
+
|
|
22
|
+
export function reserveSessionSlot(taskId) {
|
|
23
|
+
if (taskId) activeReservations.add(taskId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function releaseSessionSlot(taskId) {
|
|
27
|
+
if (taskId) activeReservations.delete(taskId)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function verifyPermissionGate({ task, permission, config }) {
|
|
31
|
+
if (!config?.permissionGateEnabled) return
|
|
32
|
+
const perm = permission || task?.permission || config?.sessionDefaultPermission || 'read-only'
|
|
33
|
+
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
34
|
+
const permRank = PERMISSION_RANKS[perm] ?? Infinity
|
|
35
|
+
const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
|
|
36
|
+
const confirmedTier = task?.permissionConfirmedTier || (task?.permissionConfirmedAt ? task?.permission : undefined)
|
|
37
|
+
const confirmedRank = confirmedTier ? (PERMISSION_RANKS[confirmedTier] ?? -1) : -1
|
|
38
|
+
|
|
39
|
+
if (permRank > defaultRank && permRank > confirmedRank) {
|
|
40
|
+
const err = new Error('Permission confirmation required')
|
|
41
|
+
err.key = 'permission-unconfirmed'
|
|
42
|
+
err.status = 403
|
|
43
|
+
throw err
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
/**
|
|
21
48
|
* Tail instructions for local board tasks.
|
|
22
49
|
*
|
|
@@ -134,20 +161,44 @@ export function applyPermission({ permissions, agent, name }) {
|
|
|
134
161
|
export async function obtainAgent({
|
|
135
162
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
136
163
|
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
164
|
+
reasoningEffort,
|
|
137
165
|
}) {
|
|
166
|
+
const chosenPerm = permission || task?.permission || ''
|
|
167
|
+
const effort = reasoningEffort || task?.reasoningEffort || undefined
|
|
168
|
+
const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }
|
|
169
|
+
|
|
138
170
|
const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
|
|
139
171
|
|
|
140
172
|
if (known !== undefined) {
|
|
141
173
|
// Live agent — reuse active session.
|
|
142
174
|
let live
|
|
143
175
|
try { live = agents.get(known) } catch { live = undefined }
|
|
144
|
-
if (live !== undefined)
|
|
176
|
+
if (live !== undefined) {
|
|
177
|
+
const access = applyPermission({ permissions, agent: live, name: chosenPerm })
|
|
178
|
+
if (access.error !== undefined) {
|
|
179
|
+
const error = new Error('access tier not set')
|
|
180
|
+
error.key = access.error
|
|
181
|
+
error.status = 409
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
return { agent: live, sessionId: known, mode: 'opened' }
|
|
185
|
+
}
|
|
145
186
|
|
|
146
187
|
// Session suspended; attempt resume.
|
|
147
188
|
try {
|
|
148
|
-
const handle = await agents.resume({ resumeSessionId: known, agentOptions
|
|
189
|
+
const handle = await agents.resume({ resumeSessionId: known, agentOptions })
|
|
190
|
+
const access = applyPermission({ permissions, agent: handle.agent, name: chosenPerm })
|
|
191
|
+
if (access.error !== undefined) {
|
|
192
|
+
const error = new Error('access tier not set')
|
|
193
|
+
error.key = access.error
|
|
194
|
+
error.status = 409
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
149
197
|
return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'resumed' }
|
|
150
198
|
} catch (error) {
|
|
199
|
+
if (error?.key === 'permission-refused' || error?.key === 'permission-unavailable' || error?.status === 409) {
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
151
202
|
logger?.warn?.(`dsh-kanban: session ${known} resume failed: ${error?.message}`)
|
|
152
203
|
}
|
|
153
204
|
}
|
|
@@ -159,30 +210,41 @@ export async function obtainAgent({
|
|
|
159
210
|
let workspaceId = task?.workspaceId || undefined
|
|
160
211
|
let worktreeCreated = false
|
|
161
212
|
|
|
162
|
-
|
|
213
|
+
const isGit = await isGitRepo(cwd, gitRunner)
|
|
214
|
+
if (config?.worktreeIsolation !== false && isGit) {
|
|
215
|
+
let created
|
|
163
216
|
try {
|
|
164
|
-
|
|
217
|
+
created = await createTaskWorktree({
|
|
165
218
|
task,
|
|
166
219
|
repoDir: cwd,
|
|
167
220
|
config,
|
|
168
221
|
gitRunner,
|
|
169
222
|
cwdOf,
|
|
170
223
|
})
|
|
171
|
-
if (created) {
|
|
172
|
-
worktreePath = created.worktreePath
|
|
173
|
-
branchName = created.branchName
|
|
174
|
-
worktreeCreated = created.created === true
|
|
175
|
-
|
|
176
|
-
if (workspaceRegistry) {
|
|
177
|
-
workspaceId = await registerDshWorkspace({
|
|
178
|
-
workspaceRegistry,
|
|
179
|
-
worktreePath,
|
|
180
|
-
title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
|
|
181
|
-
})
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
224
|
} catch (err) {
|
|
185
|
-
|
|
225
|
+
const error = new Error(`worktree-creation-failed: ${err.message}`)
|
|
226
|
+
error.key = 'worktree-creation-failed'
|
|
227
|
+
error.status = 500
|
|
228
|
+
throw error
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!created?.worktreePath) {
|
|
232
|
+
const error = new Error('worktree-creation-failed')
|
|
233
|
+
error.key = 'worktree-creation-failed'
|
|
234
|
+
error.status = 500
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
worktreePath = created.worktreePath
|
|
239
|
+
branchName = created.branchName
|
|
240
|
+
worktreeCreated = created.created === true
|
|
241
|
+
|
|
242
|
+
if (workspaceRegistry) {
|
|
243
|
+
workspaceId = await registerDshWorkspace({
|
|
244
|
+
workspaceRegistry,
|
|
245
|
+
worktreePath,
|
|
246
|
+
title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
|
|
247
|
+
})
|
|
186
248
|
}
|
|
187
249
|
}
|
|
188
250
|
|
|
@@ -197,9 +259,9 @@ export async function obtainAgent({
|
|
|
197
259
|
sessionId: mintSessionId(),
|
|
198
260
|
// Profile is set during session creation.
|
|
199
261
|
meta,
|
|
200
|
-
agentOptions
|
|
262
|
+
agentOptions,
|
|
201
263
|
})
|
|
202
|
-
const access = applyPermission({ permissions, agent: handle.agent, name:
|
|
264
|
+
const access = applyPermission({ permissions, agent: handle.agent, name: chosenPerm })
|
|
203
265
|
if (access.error !== undefined) {
|
|
204
266
|
// Session already initialized; preserve configured permissions.
|
|
205
267
|
try { handle.dispose?.() } catch { /* cleanup is secondary to rejection */ }
|
|
@@ -265,9 +327,11 @@ export async function runTask({
|
|
|
265
327
|
agents, store, task, config, provider, model,
|
|
266
328
|
mintSessionId, createMessage, cwdOf, logger, text,
|
|
267
329
|
agentPreset, permission, permissions, claimAs,
|
|
268
|
-
gitRunner, workspaceRegistry, resume = false,
|
|
330
|
+
gitRunner, workspaceRegistry, resume = false, reasoningEffort,
|
|
269
331
|
}) {
|
|
270
|
-
|
|
332
|
+
const effort = reasoningEffort || task?.reasoningEffort || undefined
|
|
333
|
+
|
|
334
|
+
// Concurrency limit check (#214, #330)
|
|
271
335
|
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
272
336
|
if (maxConcurrent > 0) {
|
|
273
337
|
const activeCount = countActiveSessions({ store, agents })
|
|
@@ -275,7 +339,16 @@ export async function runTask({
|
|
|
275
339
|
const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
|
|
276
340
|
if (!isAlreadyRunning && activeCount >= maxConcurrent) {
|
|
277
341
|
const now = Date.now()
|
|
278
|
-
store.updateTask(task.id, {
|
|
342
|
+
store.updateTask(task.id, {
|
|
343
|
+
queuedAt: task.queuedAt > 0 ? task.queuedAt : now,
|
|
344
|
+
...(provider ? { provider } : {}),
|
|
345
|
+
...(model ? { model } : {}),
|
|
346
|
+
...(permission ? { permission } : {}),
|
|
347
|
+
...(effort ? { reasoningEffort: effort } : {}),
|
|
348
|
+
queuedRequest: JSON.stringify({
|
|
349
|
+
provider, model, permission, reasoningEffort: effort, agentPreset, text,
|
|
350
|
+
}),
|
|
351
|
+
})
|
|
279
352
|
store.addTransition({
|
|
280
353
|
taskId: task.id,
|
|
281
354
|
fromCol: task.column,
|
|
@@ -286,26 +359,23 @@ export async function runTask({
|
|
|
286
359
|
return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
|
|
287
360
|
}
|
|
288
361
|
}
|
|
289
|
-
if (config?.permissionGateEnabled) {
|
|
290
|
-
const perm = permission || task.permission || 'read-only'
|
|
291
|
-
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
292
|
-
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
|
|
293
|
-
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
294
|
-
const err = new Error('Permission confirmation required')
|
|
295
|
-
err.key = 'permission-unconfirmed'
|
|
296
|
-
err.status = 403
|
|
297
|
-
throw err
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
362
|
|
|
302
|
-
|
|
303
|
-
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
304
|
-
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
305
|
-
})
|
|
363
|
+
verifyPermissionGate({ task, permission, config })
|
|
306
364
|
|
|
307
|
-
|
|
308
|
-
|
|
365
|
+
reserveSessionSlot(task.id)
|
|
366
|
+
let taken
|
|
367
|
+
try {
|
|
368
|
+
taken = await obtainAgent({
|
|
369
|
+
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
370
|
+
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
371
|
+
reasoningEffort: effort,
|
|
372
|
+
})
|
|
373
|
+
} finally {
|
|
374
|
+
releaseSessionSlot(task.id)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (task.queuedAt > 0 || task.queuedRequest) {
|
|
378
|
+
store.updateTask(task.id, { queuedAt: 0, queuedRequest: '' })
|
|
309
379
|
}
|
|
310
380
|
|
|
311
381
|
// Record run in task execution history
|
|
@@ -460,10 +530,31 @@ export async function runBatch({
|
|
|
460
530
|
const list = (tasks ?? []).filter((task) => task !== undefined)
|
|
461
531
|
if (list.length === 0) return { error: 'nothing-picked', status: 400 }
|
|
462
532
|
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
533
|
+
for (const t of list) {
|
|
534
|
+
verifyPermissionGate({ task: t, permission, config })
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
538
|
+
if (maxConcurrent > 0) {
|
|
539
|
+
const activeCount = countActiveSessions({ store, agents })
|
|
540
|
+
if (activeCount >= maxConcurrent) {
|
|
541
|
+
const err = new Error('concurrent-limit-reached')
|
|
542
|
+
err.key = 'concurrent-limit-reached'
|
|
543
|
+
err.status = 429
|
|
544
|
+
throw err
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
reserveSessionSlot(list[0].id)
|
|
549
|
+
let taken
|
|
550
|
+
try {
|
|
551
|
+
taken = await obtainAgent({
|
|
552
|
+
agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
|
|
553
|
+
agentPreset, permission, permissions,
|
|
554
|
+
})
|
|
555
|
+
} finally {
|
|
556
|
+
releaseSessionSlot(list[0].id)
|
|
557
|
+
}
|
|
467
558
|
|
|
468
559
|
await taken.agent.whenIdle()
|
|
469
560
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
@@ -473,9 +564,11 @@ export async function runBatch({
|
|
|
473
564
|
}))
|
|
474
565
|
|
|
475
566
|
if (own !== '' && list.length === 1) {
|
|
476
|
-
send(own, 'task-start')
|
|
567
|
+
await send(own, 'task-start')
|
|
477
568
|
} else {
|
|
478
|
-
|
|
569
|
+
for (let at = 0; at < list.length; at++) {
|
|
570
|
+
await send(buildQueuedMessage(list[at], config, at + 1, list.length), 'batch-queued')
|
|
571
|
+
}
|
|
479
572
|
}
|
|
480
573
|
|
|
481
574
|
const started = []
|
|
@@ -559,12 +652,23 @@ export function unqueueTask({ store, task }) {
|
|
|
559
652
|
*/
|
|
560
653
|
export function countActiveSessions({ store, agents }) {
|
|
561
654
|
const sessions = liveSessions({ store, agents })
|
|
562
|
-
|
|
655
|
+
const activeCount = sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
|
|
656
|
+
return activeCount + activeReservations.size
|
|
563
657
|
}
|
|
564
658
|
|
|
565
659
|
export function liveSessions({ store, agents }) {
|
|
566
660
|
const byId = new Map()
|
|
567
|
-
|
|
661
|
+
let all = []
|
|
662
|
+
if (typeof store?.listBoards === 'function' && typeof store?.listTasks === 'function') {
|
|
663
|
+
const boards = store.listBoards() || []
|
|
664
|
+
all = boards.flatMap((b) => store.listTasks({ board: b.id }) || [])
|
|
665
|
+
} else if (typeof store?.listTasks === 'function') {
|
|
666
|
+
all = store.listTasks() || []
|
|
667
|
+
} else if (typeof store?.listInColumn === 'function') {
|
|
668
|
+
all = store.listInColumn('in-progress') || []
|
|
669
|
+
} else if (Array.isArray(store?.tasks)) {
|
|
670
|
+
all = store.tasks
|
|
671
|
+
}
|
|
568
672
|
for (const task of all) {
|
|
569
673
|
const id = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
570
674
|
if (id === '') continue
|
|
@@ -602,19 +706,26 @@ export async function dispatchNextQueuedTask({
|
|
|
602
706
|
if (queued.length === 0) return null
|
|
603
707
|
|
|
604
708
|
const nextTask = queued[0]
|
|
709
|
+
let queuedParams = {}
|
|
710
|
+
if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
|
|
711
|
+
try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
|
|
712
|
+
}
|
|
605
713
|
try {
|
|
606
714
|
return await runTask({
|
|
607
715
|
agents,
|
|
608
716
|
store,
|
|
609
717
|
task: nextTask,
|
|
610
718
|
config,
|
|
611
|
-
provider: nextTask.provider || undefined,
|
|
612
|
-
model: nextTask.model || undefined,
|
|
719
|
+
provider: queuedParams.provider || nextTask.provider || undefined,
|
|
720
|
+
model: queuedParams.model || nextTask.model || undefined,
|
|
721
|
+
permission: queuedParams.permission || nextTask.permission || undefined,
|
|
722
|
+
reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
|
|
723
|
+
text: queuedParams.text || undefined,
|
|
724
|
+
agentPreset: queuedParams.agentPreset || agentPreset,
|
|
613
725
|
mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
|
|
614
726
|
createMessage: createMessage || ((msg) => msg),
|
|
615
727
|
cwdOf,
|
|
616
728
|
logger,
|
|
617
|
-
agentPreset,
|
|
618
729
|
permissions,
|
|
619
730
|
workspaceRegistry,
|
|
620
731
|
claimAs: claimAs || 'queue-dispatcher',
|
package/lib/order.js
CHANGED
|
@@ -1,63 +1,33 @@
|
|
|
1
1
|
// Fractional ordering index for cards.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// a single UPDATE of one row regardless of how many cards are in column.
|
|
7
|
-
//
|
|
8
|
-
// String-based implementation is intentional. Floating-point numbers fail
|
|
9
|
-
// after ~50 insertions into the same position when precision runs out.
|
|
10
|
-
//
|
|
11
|
-
// Key is read as fraction after implicit '0.': comparison is standard
|
|
12
|
-
// lexicographical. Key never ends with first alphabet character to ensure
|
|
13
|
-
// subsequent division remains possible.
|
|
14
|
-
|
|
15
|
-
/** Alphabet in ascending character code order. */
|
|
16
|
-
const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
17
|
-
const FIRST = DIGITS[0]
|
|
18
|
-
|
|
19
|
-
/** Value strictly between `a` and `b`; `b === undefined` means unbounded right. */
|
|
20
|
-
function midpoint(a, b) {
|
|
21
|
-
if (b !== undefined && a >= b) {
|
|
22
|
-
throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
|
|
23
|
-
}
|
|
24
|
-
if (a.endsWith(FIRST) || (b !== undefined && b.endsWith(FIRST))) {
|
|
25
|
-
throw new Error('order key cannot end with first alphabet symbol')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (b !== undefined) {
|
|
29
|
-
// Common prefix carried as-is, division continues on remainder
|
|
30
|
-
// to avoid inflating key length unnecessarily.
|
|
31
|
-
let n = 0
|
|
32
|
-
while ((a[n] ?? FIRST) === b[n]) n += 1
|
|
33
|
-
if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const from = a === '' ? 0 : DIGITS.indexOf(a[0])
|
|
37
|
-
const to = b === undefined ? DIGITS.length : DIGITS.indexOf(b[0])
|
|
38
|
-
if (from < 0 || to < 0) throw new Error('order key contains character outside alphabet')
|
|
3
|
+
// String-based implementation using rocicorp/fractional-indexing (CC0-1.0).
|
|
4
|
+
// Generates bounded-length order keys strictly between neighbors, avoiding
|
|
5
|
+
// O(N^2) string inflation on repeated appends/prepends.
|
|
39
6
|
|
|
40
|
-
|
|
41
|
-
return DIGITS[Math.round(0.5 * (from + to))]
|
|
42
|
-
}
|
|
43
|
-
if (b !== undefined && b.length > 1) {
|
|
44
|
-
return b.slice(0, 1)
|
|
45
|
-
}
|
|
46
|
-
// Adjacent symbols: take `a` digit and divide next position.
|
|
47
|
-
return DIGITS[from] + midpoint(a.slice(1), undefined)
|
|
48
|
-
}
|
|
7
|
+
import { generateKeyBetween, generateNKeysBetween } from './fractional-indexing.js'
|
|
49
8
|
|
|
50
9
|
/**
|
|
51
10
|
* Fractional order key strictly between two neighbors.
|
|
52
11
|
*
|
|
53
|
-
* Both neighbors are optional: without either returns default initial value;
|
|
54
|
-
* without right returns value after `a`; without left returns value before `b`.
|
|
55
|
-
*
|
|
56
12
|
* @param {string} [a] left neighbor order key
|
|
57
13
|
* @param {string} [b] right neighbor order key
|
|
58
14
|
* @returns {string} key strictly between them
|
|
59
|
-
* @throws if
|
|
15
|
+
* @throws if a >= b or key contains invalid character
|
|
60
16
|
*/
|
|
61
17
|
export function keyBetween(a, b) {
|
|
62
|
-
|
|
18
|
+
if (a !== undefined && a !== null && b !== undefined && b !== null) {
|
|
19
|
+
if (a >= b) {
|
|
20
|
+
throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (typeof a === 'string' && /[^0-9a-zA-Z]/.test(a)) {
|
|
24
|
+
throw new Error('order key contains character outside alphabet')
|
|
25
|
+
}
|
|
26
|
+
if (typeof b === 'string' && /[^0-9a-zA-Z]/.test(b)) {
|
|
27
|
+
throw new Error('order key contains character outside alphabet')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return generateKeyBetween(a ?? null, b ?? null)
|
|
63
31
|
}
|
|
32
|
+
|
|
33
|
+
export { generateKeyBetween, generateNKeysBetween }
|
package/lib/outbound.js
CHANGED
|
@@ -19,22 +19,32 @@
|
|
|
19
19
|
*/
|
|
20
20
|
export function planAssign(task, login) {
|
|
21
21
|
if (typeof task?.issueNumber !== 'number') return []
|
|
22
|
-
|
|
22
|
+
const op = {
|
|
23
23
|
kind: 'assign',
|
|
24
24
|
owner: task.owner,
|
|
25
25
|
repo: task.repo,
|
|
26
26
|
index: task.issueNumber,
|
|
27
27
|
login: typeof login === 'string' ? login : '',
|
|
28
|
-
}
|
|
28
|
+
}
|
|
29
|
+
if (task.forge) op.provider = task.forge
|
|
30
|
+
else if (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com')) op.provider = 'github'
|
|
31
|
+
if (task.issueUrl) op.issueUrl = task.issueUrl
|
|
32
|
+
return [op]
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function planOutbound(task, column, config) {
|
|
32
36
|
if (typeof task?.issueNumber !== 'number') return []
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
:
|
|
37
|
+
if (column !== 'done') return []
|
|
38
|
+
const op = {
|
|
39
|
+
kind: 'close',
|
|
40
|
+
owner: task.owner,
|
|
41
|
+
repo: task.repo,
|
|
42
|
+
index: task.issueNumber,
|
|
43
|
+
}
|
|
44
|
+
if (task.forge) op.provider = task.forge
|
|
45
|
+
else if (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com')) op.provider = 'github'
|
|
46
|
+
if (task.issueUrl) op.issueUrl = task.issueUrl
|
|
47
|
+
return [op]
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
/**
|
|
@@ -71,13 +81,19 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
|
71
81
|
for (const item of pending) {
|
|
72
82
|
try {
|
|
73
83
|
if (item.op.kind === 'close') {
|
|
74
|
-
|
|
84
|
+
const closePayload = { owner: item.op.owner, repo: item.op.repo, index: item.op.index }
|
|
85
|
+
if (item.op.provider) closePayload.provider = item.op.provider
|
|
86
|
+
if (item.op.issueUrl) closePayload.issueUrl = item.op.issueUrl
|
|
87
|
+
await gitea.closeIssue(closePayload)
|
|
75
88
|
}
|
|
76
89
|
if (item.op.kind === 'assign') {
|
|
77
|
-
|
|
90
|
+
const assignPayload = {
|
|
78
91
|
owner: item.op.owner, repo: item.op.repo, index: item.op.index,
|
|
79
92
|
logins: item.op.login === '' ? [] : [item.op.login],
|
|
80
|
-
}
|
|
93
|
+
}
|
|
94
|
+
if (item.op.provider) assignPayload.provider = item.op.provider
|
|
95
|
+
if (item.op.issueUrl) assignPayload.issueUrl = item.op.issueUrl
|
|
96
|
+
await gitea.setAssignees(assignPayload)
|
|
81
97
|
}
|
|
82
98
|
sent += 1
|
|
83
99
|
} catch (error) {
|
|
@@ -90,9 +106,10 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
|
90
106
|
dropped += 1
|
|
91
107
|
logger?.warn?.(`dsh-kanban: Gitea delivery failed permanently: ${error?.message}`)
|
|
92
108
|
try {
|
|
93
|
-
const
|
|
109
|
+
const currentStore = typeof store === 'function' ? store() : store
|
|
110
|
+
const task = currentStore?.getTask?.(item.taskId)
|
|
94
111
|
if (task !== undefined) {
|
|
95
|
-
|
|
112
|
+
currentStore?.addTransition?.({
|
|
96
113
|
taskId: item.taskId,
|
|
97
114
|
fromCol: task.column,
|
|
98
115
|
toCol: task.column,
|
package/lib/progress-dump.js
CHANGED
|
@@ -62,15 +62,22 @@ export function parseProgressDump(text) {
|
|
|
62
62
|
const line = rawLine.trim()
|
|
63
63
|
const lower = line.toLowerCase()
|
|
64
64
|
|
|
65
|
-
if (lower.startsWith('goal:') || lower.startsWith('
|
|
65
|
+
if (lower.startsWith('goal:') || lower.startsWith('цель:')) {
|
|
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('
|
|
69
|
+
} else if (lower.startsWith('progress:') || lower.startsWith('done:') || lower.startsWith('прогресс:')) {
|
|
70
70
|
currentKey = 'progress'
|
|
71
71
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
72
72
|
if (val) sections.progress.push(val)
|
|
73
|
-
} else if (
|
|
73
|
+
} else if (
|
|
74
|
+
lower.startsWith('next:') ||
|
|
75
|
+
lower.startsWith('next steps:') ||
|
|
76
|
+
lower.startsWith('next step:') ||
|
|
77
|
+
lower.startsWith('следующие шаги:') ||
|
|
78
|
+
lower.startsWith('шаги:') ||
|
|
79
|
+
lower.startsWith('дальше:')
|
|
80
|
+
) {
|
|
74
81
|
currentKey = 'next'
|
|
75
82
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
76
83
|
if (val) sections.next.push(val)
|
|
@@ -98,16 +105,14 @@ export function parseProgressDump(text) {
|
|
|
98
105
|
*/
|
|
99
106
|
export function formatProgressDump(dump) {
|
|
100
107
|
if (!dump) return ''
|
|
108
|
+
const g = dump.goal || ''
|
|
109
|
+
const p = dump.progress || ''
|
|
110
|
+
const n = dump.next || ''
|
|
101
111
|
return [
|
|
102
112
|
BEGIN_MARKER,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
'Progress:',
|
|
107
|
-
dump.progress || '—',
|
|
108
|
-
'',
|
|
109
|
-
'Next steps:',
|
|
110
|
-
dump.next || '—',
|
|
113
|
+
`Goal: ${g}`,
|
|
114
|
+
`Progress: ${p}`,
|
|
115
|
+
`Next steps: ${n}`,
|
|
111
116
|
END_MARKER,
|
|
112
117
|
].join('\n')
|
|
113
118
|
}
|
package/lib/routes.js
CHANGED
|
@@ -171,6 +171,12 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
171
171
|
export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
|
|
172
172
|
const before = store.getTask(id)
|
|
173
173
|
if (before === undefined) return { error: 'task-not-found', status: 404 }
|
|
174
|
+
if (column !== undefined && column !== null) {
|
|
175
|
+
const allowed = columnsOf(store.boardKind(before.board))
|
|
176
|
+
if (!allowed.includes(column)) {
|
|
177
|
+
return { error: 'invalid-column', status: 400 }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
174
180
|
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
175
181
|
if (task.column !== before.column) {
|
|
176
182
|
store.addTransition({
|
|
@@ -229,15 +235,30 @@ export function updateTask({ store, id, input }) {
|
|
|
229
235
|
patch.permission = input.permission
|
|
230
236
|
if (input.permission !== current.permission) {
|
|
231
237
|
patch.permissionConfirmedAt = 0
|
|
238
|
+
patch.permissionConfirmedTier = ''
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (typeof input?.permissionConfirmedTier === 'string') {
|
|
242
|
+
patch.permissionConfirmedTier = input.permissionConfirmedTier
|
|
243
|
+
}
|
|
244
|
+
if (typeof input?.reasoningEffort === 'string') {
|
|
245
|
+
const val = input.reasoningEffort.trim().toLowerCase()
|
|
246
|
+
if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
|
|
247
|
+
patch.reasoningEffort = val
|
|
232
248
|
}
|
|
233
249
|
}
|
|
250
|
+
if (typeof input?.forge === 'string') {
|
|
251
|
+
const val = input.forge.trim().toLowerCase()
|
|
252
|
+
if (val === 'github' || val === 'gitea') patch.forge = val
|
|
253
|
+
}
|
|
234
254
|
return { task: store.updateTask(id, patch) }
|
|
235
255
|
}
|
|
236
256
|
|
|
237
|
-
export function confirmPermission({ store, id }) {
|
|
257
|
+
export function confirmPermission({ store, id, tier }) {
|
|
238
258
|
const task = store.getTask(id)
|
|
239
259
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
240
|
-
|
|
260
|
+
const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
|
|
261
|
+
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
|
|
241
262
|
}
|
|
242
263
|
|
|
243
264
|
/** Known priority values. Empty string indicates unprioritized. */
|
|
@@ -399,27 +420,22 @@ export function importBoard({ store, input }) {
|
|
|
399
420
|
for (const task of input.tasks) {
|
|
400
421
|
if (typeof task?.id !== 'string' || task.id === '') continue
|
|
401
422
|
if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
|
|
402
|
-
store.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
body: task.body ?? '',
|
|
408
|
-
owner: task.owner, repo: task.repo,
|
|
409
|
-
issueNumber: task.issueNumber, issueUrl: task.issueUrl,
|
|
410
|
-
labels: Array.isArray(task.labels) ? task.labels : [],
|
|
411
|
-
branch: task.branch, worktree: task.worktree,
|
|
412
|
-
sessionId: task.sessionId, model: task.model, provider: task.provider,
|
|
413
|
-
waiting: task.waiting === true,
|
|
414
|
-
plan: task.plan ?? '',
|
|
415
|
-
labelColors: task.labelColors ?? {},
|
|
416
|
-
author: task.author ?? '',
|
|
417
|
-
priority: task.priority ?? '',
|
|
418
|
-
dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
|
|
419
|
-
createdAt: task.createdAt,
|
|
420
|
-
})
|
|
423
|
+
if (typeof store.restoreTask === 'function') {
|
|
424
|
+
store.restoreTask(task)
|
|
425
|
+
} else {
|
|
426
|
+
store.createTask(task)
|
|
427
|
+
}
|
|
421
428
|
imported += 1
|
|
422
429
|
}
|
|
430
|
+
|
|
431
|
+
for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
|
|
432
|
+
if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
|
|
433
|
+
if (typeof store.restoreTransition === 'function') {
|
|
434
|
+
store.restoreTransition(trans)
|
|
435
|
+
} else if (typeof store.addTransition === 'function') {
|
|
436
|
+
store.addTransition(trans)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
423
439
|
}
|
|
424
440
|
|
|
425
441
|
if (typeof store.withTransaction === 'function') {
|
|
@@ -458,7 +474,12 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
458
474
|
|
|
459
475
|
function escapeCsv(val) {
|
|
460
476
|
if (val === null || val === undefined) return ''
|
|
461
|
-
|
|
477
|
+
let str = String(val)
|
|
478
|
+
// OWASP CSV Injection mitigation (#340)
|
|
479
|
+
const trimmed = str.replace(/^[\s\t\r\n]+/, '')
|
|
480
|
+
if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
|
|
481
|
+
str = `'${str}`
|
|
482
|
+
}
|
|
462
483
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
463
484
|
return `"${str.replace(/"/g, '""')}"`
|
|
464
485
|
}
|
|
@@ -803,10 +824,11 @@ export function handleToggleChecklistItem({ store, task, input }) {
|
|
|
803
824
|
let updatedItem
|
|
804
825
|
if (typeof item === 'string') {
|
|
805
826
|
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
806
|
-
updatedItem = { text: item, done: checked }
|
|
827
|
+
updatedItem = { text: item, completed: checked, done: checked }
|
|
807
828
|
} else {
|
|
808
|
-
const
|
|
809
|
-
|
|
829
|
+
const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
|
|
830
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
|
|
831
|
+
updatedItem = { ...item, completed: checked, done: checked }
|
|
810
832
|
}
|
|
811
833
|
current[targetIndex] = updatedItem
|
|
812
834
|
const updated = store.updateChecklist(task.id, current)
|