@goodandready/dsh-kanban 0.2.33 → 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 +170 -379
- package/lib/config.js +31 -1
- package/lib/index.js +120 -1205
- package/lib/remote-relay.js +149 -0
- package/lib/remote-runner.js +164 -0
- 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 -1206
- 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/lib/worktree.js +35 -2
- package/package.json +1 -1
|
@@ -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
|
+
*/
|