@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/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
+