@goodandready/dsh-kanban 0.2.34 → 0.2.36
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 -0
- package/lib/client.js +3800 -3587
- package/lib/config.js +10 -0
- package/lib/dependencies.js +175 -0
- package/lib/index.js +110 -1255
- package/lib/routes/board.js +286 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +418 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +237 -0
- package/lib/routes.js +73 -1209
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +569 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +23 -2
- package/lib/time-machine.js +214 -0
- package/lib/watchdog.js +169 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -91,6 +91,11 @@ export const CONFIG_DEFAULTS = {
|
|
|
91
91
|
remoteRoot: '/mnt/external/Project/DEV',
|
|
92
92
|
remoteServerUrl: 'http://192.168.1.111:3080',
|
|
93
93
|
remoteServerToken: '',
|
|
94
|
+
watchdogEnabled: true,
|
|
95
|
+
watchdogStallTimeoutMin: 15,
|
|
96
|
+
watchdogMaxConsecutiveErrors: 5,
|
|
97
|
+
timeMachineEnabled: true,
|
|
98
|
+
timeMachineAutoSnapshot: true,
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/** Field descriptions reused in schema and settings card. */
|
|
@@ -141,6 +146,11 @@ export const CONFIG_HINTS = {
|
|
|
141
146
|
remoteRoot: 'Base directory on remote server containing project repositories (/mnt/external/Project/DEV).',
|
|
142
147
|
remoteServerUrl: 'DSH server URL on 111 (http://192.168.1.111:3080) for unified board state and sessions.',
|
|
143
148
|
remoteServerToken: 'Authentication token for remote DSH server if required.',
|
|
149
|
+
watchdogEnabled: 'Enable background watchdog timer and circuit breaker for stalled agent tasks.',
|
|
150
|
+
watchdogStallTimeoutMin: 'Minutes of inactivity before active task is marked stalled by watchdog (default: 15).',
|
|
151
|
+
watchdogMaxConsecutiveErrors: 'Number of consecutive execution errors before tripping circuit breaker (default: 5).',
|
|
152
|
+
timeMachineEnabled: 'Enable integration with @goodandready/dsh-time-machine for task checkpoints and rollback.',
|
|
153
|
+
timeMachineAutoSnapshot: 'Automatically create inception snapshot when task transitions to in-progress.',
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
/** Setting key for column WIP limit; not all columns have limits. */
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Directed Acyclic Graph (DAG) task dependency validation and auto-unblocking logic.
|
|
2
|
+
// Enforces that tasks with unresolved blockers cannot transition to in-progress.
|
|
3
|
+
// Cycle detection prevents circular dependencies (e.g. A -> B -> A).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if targetId is reachable from startId following blockedBy links.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} startId starting task identifier
|
|
9
|
+
* @param {string} targetId target task identifier
|
|
10
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
11
|
+
* @param {Set<string>} [visited] visited set to break cycles during traversal
|
|
12
|
+
* @returns {boolean} true if targetId is reachable
|
|
13
|
+
*/
|
|
14
|
+
function canReach(startId, targetId, getTaskFn, visited = new Set()) {
|
|
15
|
+
if (startId === targetId) return true
|
|
16
|
+
if (visited.has(startId)) return false
|
|
17
|
+
visited.add(startId)
|
|
18
|
+
|
|
19
|
+
const task = typeof getTaskFn === 'function' ? getTaskFn(startId) : getTaskFn?.getTask?.(startId)
|
|
20
|
+
if (!task) return false
|
|
21
|
+
|
|
22
|
+
const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
23
|
+
for (const nextId of blockers) {
|
|
24
|
+
if (typeof nextId === 'string' && nextId.trim() !== '') {
|
|
25
|
+
if (canReach(nextId, targetId, getTaskFn, visited)) return true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate proposed dependencies for a task against cycles and self-references.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} taskId task identifier
|
|
35
|
+
* @param {string[]} newBlockedBy proposed array of blocking task IDs
|
|
36
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
37
|
+
* @returns {{valid: boolean, error?: string}}
|
|
38
|
+
*/
|
|
39
|
+
export function validateDependencies(taskId, newBlockedBy, getTaskFn) {
|
|
40
|
+
if (!Array.isArray(newBlockedBy)) {
|
|
41
|
+
return { valid: true }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 1. Check self-dependency
|
|
45
|
+
if (newBlockedBy.includes(taskId)) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: `Self-dependency: task "${taskId}" cannot depend on itself.`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Check for circular dependency
|
|
53
|
+
for (const blockerId of newBlockedBy) {
|
|
54
|
+
if (typeof blockerId !== 'string' || blockerId.trim() === '') continue
|
|
55
|
+
if (canReach(blockerId, taskId, getTaskFn)) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: `Circular dependency detected: task "${blockerId}" already depends on "${taskId}".`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { valid: true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute reverse mapping (blocks) from blockedBy relations across tasks.
|
|
68
|
+
*
|
|
69
|
+
* @param {Array<object>} tasks list of all tasks
|
|
70
|
+
* @returns {Record<string, string[]>} mapping of taskId to list of tasks it blocks
|
|
71
|
+
*/
|
|
72
|
+
export function computeBlocksMap(tasks) {
|
|
73
|
+
const blocks = {}
|
|
74
|
+
if (!Array.isArray(tasks)) return blocks
|
|
75
|
+
|
|
76
|
+
for (const task of tasks) {
|
|
77
|
+
if (task && typeof task.id === 'string') {
|
|
78
|
+
blocks[task.id] = []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const task of tasks) {
|
|
83
|
+
if (!task || typeof task.id !== 'string') continue
|
|
84
|
+
const blockedBy = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
85
|
+
for (const bId of blockedBy) {
|
|
86
|
+
if (typeof bId === 'string' && bId.trim() !== '') {
|
|
87
|
+
if (!blocks[bId]) blocks[bId] = []
|
|
88
|
+
if (!blocks[bId].includes(task.id)) {
|
|
89
|
+
blocks[bId].push(task.id)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return blocks
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check whether a task is blocked by unfinished tasks.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} task task object
|
|
102
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
103
|
+
* @returns {{isBlocked: boolean, pendingBlockers: Array<object>}}
|
|
104
|
+
*/
|
|
105
|
+
export function checkTaskBlocked(task, getTaskFn) {
|
|
106
|
+
const blockedBy = Array.isArray(task?.blockedBy) ? task.blockedBy : []
|
|
107
|
+
if (blockedBy.length === 0) {
|
|
108
|
+
return { isBlocked: false, pendingBlockers: [] }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pending = []
|
|
112
|
+
for (const bId of blockedBy) {
|
|
113
|
+
if (typeof bId !== 'string' || bId.trim() === '') continue
|
|
114
|
+
const bTask = typeof getTaskFn === 'function' ? getTaskFn(bId) : getTaskFn?.getTask?.(bId)
|
|
115
|
+
if (bTask && bTask.column !== 'done') {
|
|
116
|
+
pending.push(bTask)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
isBlocked: pending.length > 0,
|
|
122
|
+
pendingBlockers: pending,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if moving a task to a target column is allowed under dependency constraints.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} task task object
|
|
130
|
+
* @param {string} targetColumn target column
|
|
131
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
132
|
+
* @returns {{allowed: boolean, reason?: string, pendingBlockers?: Array<object>}}
|
|
133
|
+
*/
|
|
134
|
+
export function checkMoveAllowed(task, targetColumn, getTaskFn) {
|
|
135
|
+
if (targetColumn === 'in-progress') {
|
|
136
|
+
const check = checkTaskBlocked(task, getTaskFn)
|
|
137
|
+
if (check.isBlocked) {
|
|
138
|
+
const names = check.pendingBlockers.map((b) => b.title || b.id).join(', ')
|
|
139
|
+
return {
|
|
140
|
+
allowed: false,
|
|
141
|
+
reason: `Cannot move card to in-progress: task is blocked by unfinished tasks (${names}). All blockers must be in done before starting.`,
|
|
142
|
+
pendingBlockers: check.pendingBlockers,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { allowed: true }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Find all tasks that were blocked by completedTaskId and are now unblocked.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} completedTaskId ID of task moving to done
|
|
153
|
+
* @param {Function|object} getTaskFn task getter function or store
|
|
154
|
+
* @param {Function} listTasksFn function returning all tasks
|
|
155
|
+
* @returns {Array<object>} tasks that transitioned from blocked to unblocked
|
|
156
|
+
*/
|
|
157
|
+
export function findUnblockedDependents(completedTaskId, getTaskFn, listTasksFn) {
|
|
158
|
+
const allTasks = typeof listTasksFn === 'function' ? listTasksFn() : []
|
|
159
|
+
const unblocked = []
|
|
160
|
+
|
|
161
|
+
for (const task of allTasks) {
|
|
162
|
+
if (!task || task.column === 'done') continue
|
|
163
|
+
const blockedBy = Array.isArray(task.blockedBy) ? task.blockedBy : []
|
|
164
|
+
if (!blockedBy.includes(completedTaskId)) continue
|
|
165
|
+
|
|
166
|
+
// Check if task is still blocked by any other unfinished task
|
|
167
|
+
const check = checkTaskBlocked(task, getTaskFn)
|
|
168
|
+
if (!check.isBlocked) {
|
|
169
|
+
unblocked.push(task)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return unblocked
|
|
174
|
+
}
|
|
175
|
+
|