@goodandready/dsh-kanban 0.2.21 → 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/fractional-indexing.js +582 -0
- package/lib/git-provider.js +11 -2
- package/lib/github.js +10 -0
- package/lib/import.js +11 -10
- package/lib/index.js +33 -8
- package/lib/launcher.js +160 -53
- package/lib/order.js +20 -50
- package/lib/outbound.js +26 -10
- package/lib/progress-dump.js +16 -11
- package/lib/routes.js +41 -25
- package/lib/store.js +156 -6
- package/lib/sync.js +6 -5
- package/lib/workflow-bridge.js +44 -18
- package/lib/worktree.js +21 -10
- package/package.json +1 -1
package/lib/import.js
CHANGED
|
@@ -152,24 +152,24 @@ export async function giteaState(gitea) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/** List open issues for import dialog, marking already-imported ones. */
|
|
155
|
-
export async function listImportable({ gitea, store, owner, repo, board = 'main' }) {
|
|
155
|
+
export async function listImportable({ gitea, store, owner, repo, board = 'main', provider, forge }) {
|
|
156
156
|
const state = await giteaState(gitea)
|
|
157
157
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
158
|
-
const issues = await gitea.listIssues({ owner, repo, state: 'open' })
|
|
158
|
+
const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
|
|
159
159
|
const existing = store.listTasks({ board })
|
|
160
160
|
return { issues: markImported(issues, existing, { owner, repo }) }
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Create a task from an issue. */
|
|
164
|
-
export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog' }) {
|
|
164
|
+
export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog', provider, forge }) {
|
|
165
165
|
const state = await giteaState(gitea)
|
|
166
166
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
167
167
|
if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
|
|
168
168
|
// Simple board is for non-issue tasks. Importing here would bring branches/PRs into columns unable to display them.
|
|
169
169
|
if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
|
|
170
|
-
const issue = await gitea.getIssue({ owner, repo, index: issueNumber })
|
|
170
|
+
const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: forge || provider })
|
|
171
171
|
if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
|
|
172
|
-
return { task: store.createTask(issueToTask(issue, { board, column, owner, repo })) }
|
|
172
|
+
return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: forge || provider })) }
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/** Re-read issue and update task, preserving local state. */
|
|
@@ -179,7 +179,8 @@ export async function refreshTask({ gitea, store, id }) {
|
|
|
179
179
|
const task = store.getTask(id)
|
|
180
180
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
181
181
|
if (typeof task.issueNumber !== 'number') return { error: 'task-has-no-issue', status: 400 }
|
|
182
|
-
const
|
|
182
|
+
const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
|
|
183
|
+
const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: forge, issueUrl: task.issueUrl })
|
|
183
184
|
if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
|
|
184
185
|
return { task: store.updateTask(id, refreshPatch(task, issue)) }
|
|
185
186
|
}
|
|
@@ -199,7 +200,7 @@ export async function searchRepos({ gitea, query, limit = 20 }) {
|
|
|
199
200
|
* Failure midway avoids leaving orphaned cards.
|
|
200
201
|
*/
|
|
201
202
|
export async function createProjectTask({
|
|
202
|
-
gitea, store, owner, repo, newRepo, title, body, board = 'main',
|
|
203
|
+
gitea, store, owner, repo, newRepo, title, body, board = 'main', provider, forge,
|
|
203
204
|
}) {
|
|
204
205
|
const state = await giteaState(gitea)
|
|
205
206
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
@@ -214,7 +215,7 @@ export async function createProjectTask({
|
|
|
214
215
|
// Validate repository name before API call.
|
|
215
216
|
if (safeSegment(wanted) === undefined) return { error: 'bad-repo-name', status: 400 }
|
|
216
217
|
try {
|
|
217
|
-
await gitea.createRepo({ owner, name: wanted })
|
|
218
|
+
await gitea.createRepo({ owner, name: wanted, provider: forge || provider })
|
|
218
219
|
} catch (error) {
|
|
219
220
|
return { error: 'repo-not-created', status: 502, detail: String(error?.message ?? error) }
|
|
220
221
|
}
|
|
@@ -223,11 +224,11 @@ export async function createProjectTask({
|
|
|
223
224
|
|
|
224
225
|
let issue
|
|
225
226
|
try {
|
|
226
|
-
issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body })
|
|
227
|
+
issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: forge || provider })
|
|
227
228
|
} catch (error) {
|
|
228
229
|
// Repository may have already been created; avoid deleting on issue creation failure.
|
|
229
230
|
return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo })) }
|
|
233
|
+
return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: forge || provider })) }
|
|
233
234
|
}
|
package/lib/index.js
CHANGED
|
@@ -807,14 +807,14 @@ export function apply(ctx, config) {
|
|
|
807
807
|
// Single setting switch controls both board tools.
|
|
808
808
|
const registerBoardTool = () => {
|
|
809
809
|
const off = [
|
|
810
|
-
ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
|
|
811
|
-
ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
|
|
812
|
-
ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
|
|
810
|
+
ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig, eventHub }))),
|
|
811
|
+
ctx.tools.register(defineTool(boardPlanDefinition({ store, eventHub }))),
|
|
812
|
+
ctx.tools.register(defineTool(boardChecklistDefinition({ store, eventHub }))),
|
|
813
813
|
ctx.tools.register(defineTool(boardReportDefinition({ store }))),
|
|
814
814
|
ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
|
|
815
815
|
ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
|
|
816
816
|
ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
|
|
817
|
-
ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
|
|
817
|
+
ctx.tools.register(defineTool(boardChecklistItemDefinition({ store, eventHub }))),
|
|
818
818
|
]
|
|
819
819
|
return () => { for (const stop of off) stop?.() }
|
|
820
820
|
}
|
|
@@ -946,7 +946,11 @@ export function apply(ctx, config) {
|
|
|
946
946
|
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
947
947
|
try {
|
|
948
948
|
const input = await readBody(req)
|
|
949
|
-
|
|
949
|
+
const impResult = importBoard({ store, input })
|
|
950
|
+
if (impResult?.imported > 0) {
|
|
951
|
+
eventHub.broadcast('task:change', { action: 'imported', count: impResult.imported })
|
|
952
|
+
}
|
|
953
|
+
reply(res, impResult)
|
|
950
954
|
} catch {
|
|
951
955
|
writeJson(res, 400, { error: 'bad-request' })
|
|
952
956
|
}
|
|
@@ -965,7 +969,11 @@ export function apply(ctx, config) {
|
|
|
965
969
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
966
970
|
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
967
971
|
try {
|
|
968
|
-
|
|
972
|
+
const resObj = createTask({ store, input: await readBody(req) })
|
|
973
|
+
if (resObj?.task) {
|
|
974
|
+
eventHub.broadcast('task:change', { taskId: resObj.task.id, action: 'created' })
|
|
975
|
+
}
|
|
976
|
+
reply(res, resObj)
|
|
969
977
|
} catch (error) {
|
|
970
978
|
writeJson(res, error?.status ?? 500, { error: 'bad-request' })
|
|
971
979
|
}
|
|
@@ -987,12 +995,23 @@ export function apply(ctx, config) {
|
|
|
987
995
|
return
|
|
988
996
|
}
|
|
989
997
|
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
990
|
-
if (req.method === 'DELETE' && action === undefined) {
|
|
998
|
+
if (req.method === 'DELETE' && action === undefined) {
|
|
999
|
+
const resObj = deleteTask({ store, id })
|
|
1000
|
+
if (resObj?.deleted) {
|
|
1001
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'deleted' })
|
|
1002
|
+
}
|
|
1003
|
+
reply(res, resObj)
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
991
1006
|
// Card fields update: title, body, labels, priority. Priority is board-local.
|
|
992
1007
|
if (req.method === 'POST' && action === 'update') {
|
|
993
1008
|
try {
|
|
994
1009
|
const input = await readBody(req)
|
|
995
|
-
|
|
1010
|
+
const resObj = updateTask({ store, id, input })
|
|
1011
|
+
if (resObj?.task) {
|
|
1012
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1013
|
+
}
|
|
1014
|
+
reply(res, resObj)
|
|
996
1015
|
} catch {
|
|
997
1016
|
writeJson(res, 400, { error: 'bad-request' })
|
|
998
1017
|
}
|
|
@@ -1169,6 +1188,9 @@ export function apply(ctx, config) {
|
|
|
1169
1188
|
}
|
|
1170
1189
|
if (req.method === 'POST' && action === 'checklist-toggle') {
|
|
1171
1190
|
const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
|
|
1191
|
+
if (resObj?.body?.task) {
|
|
1192
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1193
|
+
}
|
|
1172
1194
|
writeJson(res, resObj.status, resObj.body)
|
|
1173
1195
|
return
|
|
1174
1196
|
}
|
|
@@ -1179,6 +1201,9 @@ export function apply(ctx, config) {
|
|
|
1179
1201
|
}
|
|
1180
1202
|
if (req.method === 'POST' && action === 'checklist') {
|
|
1181
1203
|
const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
|
|
1204
|
+
if (resObj?.body?.task) {
|
|
1205
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1206
|
+
}
|
|
1182
1207
|
writeJson(res, resObj.status, resObj.body)
|
|
1183
1208
|
return
|
|
1184
1209
|
}
|
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,28 +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
|
-
const permRank = PERMISSION_RANKS[perm] ?? Infinity
|
|
293
|
-
const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
|
|
294
|
-
if (permRank > defaultRank) {
|
|
295
|
-
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
296
|
-
const err = new Error('Permission confirmation required')
|
|
297
|
-
err.key = 'permission-unconfirmed'
|
|
298
|
-
err.status = 403
|
|
299
|
-
throw err
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
362
|
|
|
304
|
-
|
|
305
|
-
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
306
|
-
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
307
|
-
})
|
|
363
|
+
verifyPermissionGate({ task, permission, config })
|
|
308
364
|
|
|
309
|
-
|
|
310
|
-
|
|
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: '' })
|
|
311
379
|
}
|
|
312
380
|
|
|
313
381
|
// Record run in task execution history
|
|
@@ -462,10 +530,31 @@ export async function runBatch({
|
|
|
462
530
|
const list = (tasks ?? []).filter((task) => task !== undefined)
|
|
463
531
|
if (list.length === 0) return { error: 'nothing-picked', status: 400 }
|
|
464
532
|
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
}
|
|
469
558
|
|
|
470
559
|
await taken.agent.whenIdle()
|
|
471
560
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
@@ -563,12 +652,23 @@ export function unqueueTask({ store, task }) {
|
|
|
563
652
|
*/
|
|
564
653
|
export function countActiveSessions({ store, agents }) {
|
|
565
654
|
const sessions = liveSessions({ store, agents })
|
|
566
|
-
|
|
655
|
+
const activeCount = sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
|
|
656
|
+
return activeCount + activeReservations.size
|
|
567
657
|
}
|
|
568
658
|
|
|
569
659
|
export function liveSessions({ store, agents }) {
|
|
570
660
|
const byId = new Map()
|
|
571
|
-
|
|
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
|
+
}
|
|
572
672
|
for (const task of all) {
|
|
573
673
|
const id = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
574
674
|
if (id === '') continue
|
|
@@ -606,19 +706,26 @@ export async function dispatchNextQueuedTask({
|
|
|
606
706
|
if (queued.length === 0) return null
|
|
607
707
|
|
|
608
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
|
+
}
|
|
609
713
|
try {
|
|
610
714
|
return await runTask({
|
|
611
715
|
agents,
|
|
612
716
|
store,
|
|
613
717
|
task: nextTask,
|
|
614
718
|
config,
|
|
615
|
-
provider: nextTask.provider || undefined,
|
|
616
|
-
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,
|
|
617
725
|
mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
|
|
618
726
|
createMessage: createMessage || ((msg) => msg),
|
|
619
727
|
cwdOf,
|
|
620
728
|
logger,
|
|
621
|
-
agentPreset,
|
|
622
729
|
permissions,
|
|
623
730
|
workspaceRegistry,
|
|
624
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) {
|