@goodandready/dsh-kanban 0.2.34 → 0.2.35
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 +30 -34
- package/lib/config.js +6 -0
- package/lib/index.js +110 -1255
- package/lib/routes/board.js +282 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +345 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +211 -0
- package/lib/routes.js +71 -1209
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +530 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +9 -2
- package/lib/watchdog.js +169 -0
- package/package.json +1 -1
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
|
|
3
|
+
* Zero Cyrillic in lib/ according to Issue #366.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function handleTaskChecklist({ store, task, input }) {
|
|
7
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
8
|
+
const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
|
|
9
|
+
const updated = store.updateChecklist(task.id, list)
|
|
10
|
+
return { status: 200, body: { task: updated } }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Set completion report for task.
|
|
15
|
+
*/
|
|
16
|
+
export function handleTaskReport({ store, task, input }) {
|
|
17
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
18
|
+
const updated = store.setReport(task.id, input)
|
|
19
|
+
return { status: 200, body: { task: updated } }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get comments for task.
|
|
24
|
+
*/
|
|
25
|
+
export function handleGetTaskComments({ task }) {
|
|
26
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
27
|
+
return { status: 200, body: { comments: task.comments || [] } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add comment to task.
|
|
32
|
+
*/
|
|
33
|
+
export function handleAddTaskComment({ store, task, input }) {
|
|
34
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
35
|
+
const author = input?.author || 'user'
|
|
36
|
+
const role = input?.role || 'user'
|
|
37
|
+
const text = input?.text || ''
|
|
38
|
+
const updated = store.addComment(task.id, { author, role, text })
|
|
39
|
+
return { status: 200, body: { task: updated } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Accept task review (transition to done).
|
|
44
|
+
*/
|
|
45
|
+
export function handleAcceptTask({ store, task }) {
|
|
46
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
47
|
+
const updated = store.acceptTask(task.id)
|
|
48
|
+
return { status: 200, body: { task: updated } }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reject task review (return to in_progress).
|
|
53
|
+
*/
|
|
54
|
+
export function handleRejectTask({ store, task, input }) {
|
|
55
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
56
|
+
const reason = String(input?.reason || '').trim()
|
|
57
|
+
if (!reason) return { status: 400, body: { error: 'reason-required' } }
|
|
58
|
+
const updated = store.rejectTask(task.id, reason)
|
|
59
|
+
return { status: 200, body: { task: updated } }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Save task progress dump snapshot.
|
|
64
|
+
*/
|
|
65
|
+
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
66
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
67
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
68
|
+
const updated = store.updateTask(task.id, { progressDump: formatted })
|
|
69
|
+
return { status: 200, body: { task: updated } }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decompose a task into subtasks and make the parent an Epic.
|
|
74
|
+
*/
|
|
75
|
+
export async function handleTriageTask({ store, gitea, task, input }) {
|
|
76
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
77
|
+
const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
|
|
78
|
+
if (subtasksInput.length === 0) {
|
|
79
|
+
return { status: 400, body: { error: 'subtasks-required' } }
|
|
80
|
+
}
|
|
81
|
+
const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
|
|
82
|
+
|
|
83
|
+
// Convert parent into Epic
|
|
84
|
+
store.updateTask(task.id, { isEpic: true })
|
|
85
|
+
|
|
86
|
+
const created = []
|
|
87
|
+
for (const item of subtasksInput) {
|
|
88
|
+
const title = String(item?.title ?? '').trim()
|
|
89
|
+
if (!title) continue
|
|
90
|
+
const body = String(item?.body ?? '').trim()
|
|
91
|
+
let issueNumber = null
|
|
92
|
+
let issueUrl = null
|
|
93
|
+
|
|
94
|
+
if (createGitea) {
|
|
95
|
+
try {
|
|
96
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
97
|
+
const giteaIssue = await gitea.createIssue({
|
|
98
|
+
owner: task.owner,
|
|
99
|
+
repo: task.repo,
|
|
100
|
+
title,
|
|
101
|
+
body: issueBody,
|
|
102
|
+
})
|
|
103
|
+
if (giteaIssue?.number) {
|
|
104
|
+
issueNumber = giteaIssue.number
|
|
105
|
+
issueUrl = giteaIssue.html_url || null
|
|
106
|
+
}
|
|
107
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sub = store.createTask({
|
|
111
|
+
board: task.board || 'main',
|
|
112
|
+
column: 'backlog',
|
|
113
|
+
title,
|
|
114
|
+
body,
|
|
115
|
+
parentId: task.id,
|
|
116
|
+
owner: task.owner || null,
|
|
117
|
+
repo: task.repo || null,
|
|
118
|
+
issueNumber,
|
|
119
|
+
issueUrl,
|
|
120
|
+
labels: Array.isArray(item?.labels) ? item.labels : [],
|
|
121
|
+
workspaceId: task.workspaceId || '',
|
|
122
|
+
})
|
|
123
|
+
created.push(sub)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
store.addTransition({
|
|
127
|
+
taskId: task.id,
|
|
128
|
+
fromCol: task.column,
|
|
129
|
+
toCol: task.column,
|
|
130
|
+
source: 'manual',
|
|
131
|
+
detail: `decomposed into ${created.length} subtasks (epic triage)`,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
status: 200,
|
|
136
|
+
body: {
|
|
137
|
+
epic: store.getTask(task.id),
|
|
138
|
+
subtasks: store.listSubtasks(task.id),
|
|
139
|
+
counts: store.countSubtasks(task.id),
|
|
140
|
+
createdCount: created.length,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get subtasks of a task.
|
|
147
|
+
*/
|
|
148
|
+
export function handleGetSubtasks({ store, task }) {
|
|
149
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
150
|
+
return {
|
|
151
|
+
status: 200,
|
|
152
|
+
body: {
|
|
153
|
+
subtasks: store.listSubtasks(task.id),
|
|
154
|
+
counts: store.countSubtasks(task.id),
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create a single subtask linked to a parent task.
|
|
161
|
+
*/
|
|
162
|
+
export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
163
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
164
|
+
const title = String(input?.title ?? '').trim()
|
|
165
|
+
if (!title) return { status: 400, body: { error: 'title-required' } }
|
|
166
|
+
const body = String(input?.body ?? '').trim()
|
|
167
|
+
let issueNumber = null
|
|
168
|
+
let issueUrl = null
|
|
169
|
+
|
|
170
|
+
if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
|
|
171
|
+
try {
|
|
172
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
173
|
+
const giteaIssue = await gitea.createIssue({
|
|
174
|
+
owner: task.owner,
|
|
175
|
+
repo: task.repo,
|
|
176
|
+
title,
|
|
177
|
+
body: issueBody,
|
|
178
|
+
})
|
|
179
|
+
if (giteaIssue?.number) {
|
|
180
|
+
issueNumber = giteaIssue.number
|
|
181
|
+
issueUrl = giteaIssue.html_url || null
|
|
182
|
+
}
|
|
183
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
store.updateTask(task.id, { isEpic: true })
|
|
187
|
+
const sub = store.createTask({
|
|
188
|
+
board: task.board || 'main',
|
|
189
|
+
column: input?.column || 'backlog',
|
|
190
|
+
title,
|
|
191
|
+
body,
|
|
192
|
+
parentId: task.id,
|
|
193
|
+
owner: task.owner || null,
|
|
194
|
+
repo: task.repo || null,
|
|
195
|
+
issueNumber,
|
|
196
|
+
issueUrl,
|
|
197
|
+
labels: Array.isArray(input?.labels) ? input.labels : [],
|
|
198
|
+
workspaceId: task.workspaceId || '',
|
|
199
|
+
})
|
|
200
|
+
return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Toggle a checklist item atomically by index or text.
|
|
205
|
+
*/
|
|
206
|
+
export function handleToggleChecklistItem({ store, task, input }) {
|
|
207
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
208
|
+
const current = Array.isArray(task.checklist) ? [...task.checklist] : []
|
|
209
|
+
let targetIndex = -1
|
|
210
|
+
|
|
211
|
+
if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
|
|
212
|
+
targetIndex = input.index
|
|
213
|
+
} else if (typeof input?.text === 'string' && input.text.trim()) {
|
|
214
|
+
const q = input.text.trim().toLowerCase()
|
|
215
|
+
targetIndex = current.findIndex((item) => {
|
|
216
|
+
const t = typeof item === 'string' ? item : item?.text
|
|
217
|
+
return typeof t === 'string' && t.toLowerCase().includes(q)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (targetIndex === -1) {
|
|
222
|
+
return { status: 404, body: { error: 'checklist-item-not-found' } }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const item = current[targetIndex]
|
|
226
|
+
let updatedItem
|
|
227
|
+
if (typeof item === 'string') {
|
|
228
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
229
|
+
updatedItem = { text: item, completed: checked, done: checked }
|
|
230
|
+
} else {
|
|
231
|
+
const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
|
|
232
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
|
|
233
|
+
updatedItem = { ...item, completed: checked, done: checked }
|
|
234
|
+
}
|
|
235
|
+
current[targetIndex] = updatedItem
|
|
236
|
+
const updated = store.updateChecklist(task.id, current)
|
|
237
|
+
return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create a Gitea Pull Request for a task.
|
|
242
|
+
*/
|
|
243
|
+
export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
244
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
245
|
+
if (!task.owner || !task.repo) {
|
|
246
|
+
return { status: 400, body: { error: 'task-no-repo' } }
|
|
247
|
+
}
|
|
248
|
+
const head = String(input?.head || task.branch || '').trim()
|
|
249
|
+
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
250
|
+
const base = String(input?.base || 'main').trim()
|
|
251
|
+
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
252
|
+
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const pr = await gitea.createPullRequest({
|
|
256
|
+
owner: task.owner,
|
|
257
|
+
repo: task.repo,
|
|
258
|
+
title,
|
|
259
|
+
body,
|
|
260
|
+
head,
|
|
261
|
+
base,
|
|
262
|
+
})
|
|
263
|
+
store.addTransition({
|
|
264
|
+
taskId: task.id,
|
|
265
|
+
fromCol: task.column,
|
|
266
|
+
toCol: task.column,
|
|
267
|
+
source: 'manual',
|
|
268
|
+
detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
|
|
269
|
+
})
|
|
270
|
+
return { status: 200, body: { pr, prUrl: pr?.html_url } }
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Handle interactive live steering (nudge) for an active task agent (#373).
|
|
277
|
+
*
|
|
278
|
+
* @param {{
|
|
279
|
+
* store: object,
|
|
280
|
+
* id: string,
|
|
281
|
+
* prompt: string,
|
|
282
|
+
* agents?: object,
|
|
283
|
+
* createMessage?: Function,
|
|
284
|
+
* eventHub?: object,
|
|
285
|
+
* now?: number
|
|
286
|
+
* }} options
|
|
287
|
+
* @returns {Promise<{ status: number, body: object }>}
|
|
288
|
+
*/
|
|
289
|
+
export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
|
|
290
|
+
if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
|
|
291
|
+
const task = store.getTask(id)
|
|
292
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
293
|
+
|
|
294
|
+
const text = typeof prompt === 'string' ? prompt.trim() : ''
|
|
295
|
+
if (!text) return { status: 400, body: { error: 'prompt-required' } }
|
|
296
|
+
|
|
297
|
+
const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
298
|
+
if (!sessionId) return { status: 409, body: { error: 'no-session' } }
|
|
299
|
+
|
|
300
|
+
let agent
|
|
301
|
+
try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
|
|
302
|
+
if (!agent || typeof agent.followup !== 'function') {
|
|
303
|
+
return { status: 409, body: { error: 'session-not-live' } }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const msg = typeof createMessage === 'function'
|
|
307
|
+
? createMessage({
|
|
308
|
+
content: [{ type: 'text', text }],
|
|
309
|
+
source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
|
|
310
|
+
})
|
|
311
|
+
: { content: [{ type: 'text', text }] }
|
|
312
|
+
|
|
313
|
+
agent.followup(msg)
|
|
314
|
+
|
|
315
|
+
// Append user steering comment to task
|
|
316
|
+
if (typeof store.addComment === 'function') {
|
|
317
|
+
store.addComment(id, {
|
|
318
|
+
author: 'user',
|
|
319
|
+
role: 'user',
|
|
320
|
+
text: `[Steering Nudge] ${text}`,
|
|
321
|
+
})
|
|
322
|
+
} else if (typeof store.addTaskComment === 'function') {
|
|
323
|
+
store.addTaskComment(id, {
|
|
324
|
+
author: 'user',
|
|
325
|
+
role: 'user',
|
|
326
|
+
text: `[Steering Nudge] ${text}`,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Broadcast change via eventHub
|
|
331
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
332
|
+
eventHub.broadcast('task:change', { id, col: task.col || task.column })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
status: 200,
|
|
337
|
+
body: {
|
|
338
|
+
success: true,
|
|
339
|
+
id,
|
|
340
|
+
sessionId,
|
|
341
|
+
prompt: text,
|
|
342
|
+
nudgedAt: now,
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree actions for task route handlers.
|
|
3
|
+
* Zero Cyrillic in lib/ according to Issue #366.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function handleCleanupWorktree({
|
|
7
|
+
store, task, workspaceRegistry, force, cleanupTaskWorktree,
|
|
8
|
+
config, repoDir, cwdOf, env, gitRunner,
|
|
9
|
+
}) {
|
|
10
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
11
|
+
const out = await cleanupTaskWorktree({
|
|
12
|
+
store,
|
|
13
|
+
task,
|
|
14
|
+
workspaceRegistry,
|
|
15
|
+
force: force === true,
|
|
16
|
+
config,
|
|
17
|
+
repoDir,
|
|
18
|
+
cwdOf,
|
|
19
|
+
env,
|
|
20
|
+
gitRunner,
|
|
21
|
+
})
|
|
22
|
+
const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
|
|
23
|
+
return { status, body: out }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get working copy diff for task.
|
|
28
|
+
*/
|
|
29
|
+
export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }) {
|
|
30
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
31
|
+
if (typeof getTaskDiffInspector === 'function') {
|
|
32
|
+
const inspectorResult = await getTaskDiffInspector({ worktreePath: task.worktree })
|
|
33
|
+
return { status: 200, body: inspectorResult }
|
|
34
|
+
}
|
|
35
|
+
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
36
|
+
return { status: 200, body: diffResult }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Discard file changes in task worktree (#371).
|
|
41
|
+
*/
|
|
42
|
+
export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
|
|
43
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
44
|
+
if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
|
|
45
|
+
if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
|
|
46
|
+
const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
|
|
47
|
+
return { status: result.success ? 200 : 400, body: result }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get commit list for task.
|
|
52
|
+
*/
|
|
53
|
+
export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
|
|
54
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
55
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
|
|
56
|
+
return { status: 200, body: { commits } }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Merge task worktree branch and transition to done.
|
|
61
|
+
*/
|
|
62
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
|
|
63
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
64
|
+
const mergeResult = await mergeTaskWorktree({
|
|
65
|
+
task,
|
|
66
|
+
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
67
|
+
workspaceRegistry,
|
|
68
|
+
gitRunner,
|
|
69
|
+
})
|
|
70
|
+
if (!mergeResult.success) {
|
|
71
|
+
return {
|
|
72
|
+
status: 400,
|
|
73
|
+
body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const updated = store.acceptTask(task.id)
|
|
77
|
+
store.addTransition({
|
|
78
|
+
taskId: task.id,
|
|
79
|
+
fromCol: task.column,
|
|
80
|
+
toCol: 'done',
|
|
81
|
+
source: 'manual',
|
|
82
|
+
detail: `accepted and merged branch ${task.branch} into ${mergeResult.targetBranch}`,
|
|
83
|
+
})
|
|
84
|
+
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check task worktree status (branch, dirty files, workspace).
|
|
89
|
+
*/
|
|
90
|
+
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
91
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
92
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
93
|
+
return {
|
|
94
|
+
status: 200,
|
|
95
|
+
body: {
|
|
96
|
+
worktree: task.worktree || '',
|
|
97
|
+
branch: task.branch || '',
|
|
98
|
+
workspaceId: task.workspaceId || '',
|
|
99
|
+
dirty: dirtyCheck.dirty,
|
|
100
|
+
files: dirtyCheck.files,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Update Definition of Done checklist for task.
|
|
107
|
+
*/
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { parseBody } from '../markdown.js'
|
|
2
|
+
import { columnsOf, wipLimitField } from '../config.js'
|
|
3
|
+
import { withPlan } from './board.js'
|
|
4
|
+
|
|
5
|
+
export function parseTaskPath(pathname) {
|
|
6
|
+
const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
|
|
7
|
+
if (m === null) return undefined
|
|
8
|
+
return { id: decodeURIComponent(m[1]), action: m[2] }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Enrich task with parsed plan, state, and filters.
|
|
13
|
+
*
|
|
14
|
+
* Plan is pre-parsed on server because client bundle cannot import `lib/plan.js`.
|
|
15
|
+
* `liveOf` returns active agent state or undefined if session is inactive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
|
|
19
|
+
const before = store.getTask(id)
|
|
20
|
+
if (before === undefined) return { error: 'task-not-found', status: 404 }
|
|
21
|
+
if (column !== undefined && column !== null) {
|
|
22
|
+
const allowed = columnsOf(store.boardKind(before.board))
|
|
23
|
+
if (!allowed.includes(column)) {
|
|
24
|
+
return { error: 'invalid-column', status: 400 }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
28
|
+
if (task.column !== before.column) {
|
|
29
|
+
store.addTransition({
|
|
30
|
+
taskId: id, fromCol: before.column, toCol: task.column, source, detail,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
const limit = wipLimitField(config, task.column)
|
|
34
|
+
const count = store.countInColumn(task.board, task.column)
|
|
35
|
+
return { task, overLimit: limit !== undefined && count > limit }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Create local board task (not linked to Gitea). */
|
|
39
|
+
export function createTask({ store, input }) {
|
|
40
|
+
const title = typeof input?.title === 'string' ? input.title.trim() : ''
|
|
41
|
+
if (title === '') return { error: 'title-required', status: 400 }
|
|
42
|
+
const board = input?.board ?? 'main'
|
|
43
|
+
// Validate column against board kind; simple boards do not have deploy/cleanup.
|
|
44
|
+
const allowed = columnsOf(store.boardKind(board))
|
|
45
|
+
const column = allowed.includes(input?.column) ? input.column : 'backlog'
|
|
46
|
+
const task = store.createTask({
|
|
47
|
+
board,
|
|
48
|
+
column,
|
|
49
|
+
title,
|
|
50
|
+
body: typeof input?.body === 'string' ? input.body : '',
|
|
51
|
+
labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
|
|
52
|
+
})
|
|
53
|
+
return { task }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Update card fields. Column transitions are handled via applyMove. */
|
|
57
|
+
export function updateTask({ store, id, input }) {
|
|
58
|
+
const current = store.getTask(id)
|
|
59
|
+
if (current === undefined) return { error: 'task-not-found', status: 404 }
|
|
60
|
+
const patch = {}
|
|
61
|
+
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
62
|
+
if (typeof input?.body === 'string') patch.body = input.body
|
|
63
|
+
if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
|
|
64
|
+
// Priority is local to board and not synced back to Gitea.
|
|
65
|
+
if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
|
|
66
|
+
// Deadline is local. 0 clears deadline; positive value sets it.
|
|
67
|
+
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
68
|
+
patch.dueAt = Math.floor(input.dueAt)
|
|
69
|
+
}
|
|
70
|
+
if (typeof input?.cron === 'string') {
|
|
71
|
+
patch.cron = input.cron.trim()
|
|
72
|
+
patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(input?.checklist)) patch.checklist = input.checklist
|
|
75
|
+
if (input?.report && typeof input.report === 'object') patch.report = input.report
|
|
76
|
+
if (Array.isArray(input?.comments)) patch.comments = input.comments
|
|
77
|
+
if (typeof input?.rejectReason === 'string') patch.rejectReason = input.rejectReason
|
|
78
|
+
if (typeof input?.progressDump === 'string') {
|
|
79
|
+
patch.progressDump = input.progressDump
|
|
80
|
+
}
|
|
81
|
+
if (typeof input?.permission === 'string') {
|
|
82
|
+
patch.permission = input.permission
|
|
83
|
+
if (input.permission !== current.permission) {
|
|
84
|
+
patch.permissionConfirmedAt = 0
|
|
85
|
+
patch.permissionConfirmedTier = ''
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof input?.reasoningEffort === 'string') {
|
|
90
|
+
const val = input.reasoningEffort.trim().toLowerCase()
|
|
91
|
+
if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
|
|
92
|
+
patch.reasoningEffort = val
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (typeof input?.forge === 'string') {
|
|
96
|
+
const val = input.forge.trim().toLowerCase()
|
|
97
|
+
if (val === 'github' || val === 'gitea') patch.forge = val
|
|
98
|
+
}
|
|
99
|
+
return { task: store.updateTask(id, patch) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function confirmPermission({ store, id, tier }) {
|
|
103
|
+
const task = store.getTask(id)
|
|
104
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
105
|
+
const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
|
|
106
|
+
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Known priority values. Empty string indicates unprioritized. */
|
|
110
|
+
const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Append note to task body.
|
|
114
|
+
* Handled on server to prevent overwriting edits made elsewhere.
|
|
115
|
+
*/
|
|
116
|
+
export function appendNote({ store, id, input }) {
|
|
117
|
+
const task = store.getTask(id)
|
|
118
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
119
|
+
const note = typeof input?.text === 'string' ? input.text.trim() : ''
|
|
120
|
+
if (note === '') return { error: 'note-required', status: 400 }
|
|
121
|
+
const body = typeof task.body === 'string' ? task.body : ''
|
|
122
|
+
return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Archive or unarchive card.
|
|
127
|
+
* Unarchiving restores card to its original column and position.
|
|
128
|
+
*/
|
|
129
|
+
export function setArchived({ store, id, archived, now = Date.now() }) {
|
|
130
|
+
const task = store.getTask(id)
|
|
131
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
132
|
+
return { task: store.setArchived(id, archived ? now : 0) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Query archived tasks list. */
|
|
136
|
+
export function listArchive({ store }) {
|
|
137
|
+
return { tasks: store.listArchived() }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Assign task or clear assignment.
|
|
142
|
+
* Empty login unassigns the task.
|
|
143
|
+
*
|
|
144
|
+
* @returns {{task: object}|{error: string, status: number}}
|
|
145
|
+
*/
|
|
146
|
+
export function assignTask({ store, id, login }) {
|
|
147
|
+
const task = store.getTask(id)
|
|
148
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
149
|
+
const who = typeof login === 'string' ? login.trim() : ''
|
|
150
|
+
if (who === (task.assignee ?? '')) return { task }
|
|
151
|
+
store.updateTask(id, { assignee: who })
|
|
152
|
+
store.addTransition({
|
|
153
|
+
taskId: id,
|
|
154
|
+
fromCol: task.column,
|
|
155
|
+
toCol: task.column,
|
|
156
|
+
source: 'manual',
|
|
157
|
+
detail: who === '' ? 'unassigned' : `assigned: ${who}`,
|
|
158
|
+
})
|
|
159
|
+
return { task: store.getTask(id) }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function deleteTask({ store, id }) {
|
|
163
|
+
if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
|
|
164
|
+
store.deleteTask(id)
|
|
165
|
+
return { ok: true }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse task markdown body into structured block elements.
|
|
170
|
+
* Done on server so client bundle does not need markdown parser.
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* Board metrics: WIP distribution and stale tasks.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{columns: object[], done: object, stale: object[], tasks: number}}
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
export function canRevive(task, live) {
|
|
179
|
+
if (task?.stalled) return true
|
|
180
|
+
const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
|
|
181
|
+
return has && (live === undefined || live === null)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function taskBody({ store, id }) {
|
|
185
|
+
const task = store.getTask(id)
|
|
186
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
187
|
+
return { blocks: parseBody(task.body) }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function taskLog({ store, id }) {
|
|
191
|
+
if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
|
|
192
|
+
return { transitions: store.listTransitions(id) }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Find task associated with session; absence is normal response. */
|
|
196
|
+
export function taskBySession({ store, sessionId, agents }) {
|
|
197
|
+
const task = store.findTaskBySession(sessionId)
|
|
198
|
+
if (task === undefined) return { task: null }
|
|
199
|
+
let live
|
|
200
|
+
try { live = agents?.get?.(sessionId) } catch { live = undefined }
|
|
201
|
+
// Board kind returned with task so UI displays matching column choices.
|
|
202
|
+
return {
|
|
203
|
+
task,
|
|
204
|
+
columns: columnsOf(store.boardKind(task.board)),
|
|
205
|
+
canRevive: canRevive(task, live),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Export full board snapshot for backup or migration.
|
|
211
|
+
*/
|