@goodandready/dsh-kanban 0.2.35 → 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 +3784 -3567
- package/lib/config.js +4 -0
- package/lib/dependencies.js +175 -0
- package/lib/routes/board.js +4 -0
- package/lib/routes/task-actions.js +73 -0
- package/lib/routes/task.js +27 -1
- package/lib/routes.js +2 -0
- package/lib/server/task-handler.js +39 -0
- package/lib/store.js +15 -1
- package/lib/time-machine.js +214 -0
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -94,6 +94,8 @@ export const CONFIG_DEFAULTS = {
|
|
|
94
94
|
watchdogEnabled: true,
|
|
95
95
|
watchdogStallTimeoutMin: 15,
|
|
96
96
|
watchdogMaxConsecutiveErrors: 5,
|
|
97
|
+
timeMachineEnabled: true,
|
|
98
|
+
timeMachineAutoSnapshot: true,
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/** Field descriptions reused in schema and settings card. */
|
|
@@ -147,6 +149,8 @@ export const CONFIG_HINTS = {
|
|
|
147
149
|
watchdogEnabled: 'Enable background watchdog timer and circuit breaker for stalled agent tasks.',
|
|
148
150
|
watchdogStallTimeoutMin: 'Minutes of inactivity before active task is marked stalled by watchdog (default: 15).',
|
|
149
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.',
|
|
150
154
|
}
|
|
151
155
|
|
|
152
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
|
+
|
package/lib/routes/board.js
CHANGED
|
@@ -4,6 +4,7 @@ import { columnStats, doneWithin, stale } from '../metrics.js'
|
|
|
4
4
|
import { isStale } from '../time.js'
|
|
5
5
|
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from '../filters.js'
|
|
6
6
|
import { computeNextRun } from '../cron.js'
|
|
7
|
+
import { computeBlocksMap, checkTaskBlocked } from '../dependencies.js'
|
|
7
8
|
|
|
8
9
|
export function withPlan(task, liveOf, config, now, sameSession) {
|
|
9
10
|
const plan = parsePlan(task.plan)
|
|
@@ -74,12 +75,15 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
74
75
|
if (!bySession.has(id)) bySession.set(id, [])
|
|
75
76
|
bySession.get(id).push(task)
|
|
76
77
|
}
|
|
78
|
+
const blocksMap = computeBlocksMap(raw)
|
|
77
79
|
const tasks = orderForBoard(
|
|
78
80
|
raw.map((task) => {
|
|
79
81
|
const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
|
|
80
82
|
if (task.isEpic) {
|
|
81
83
|
enriched.subtasksCount = store.countSubtasks(task.id)
|
|
82
84
|
}
|
|
85
|
+
enriched.blocks = blocksMap[task.id] || []
|
|
86
|
+
enriched.isBlocked = checkTaskBlocked(task, store).isBlocked
|
|
83
87
|
return enriched
|
|
84
88
|
}),
|
|
85
89
|
columnsOf(store.boardKind(board)),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isTimeMachineAvailable, listTaskSnapshots, createCheckpointSnapshot, rollbackToSnapshot } from '../time-machine.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
|
|
3
5
|
* Zero Cyrillic in lib/ according to Issue #366.
|
|
@@ -343,3 +345,74 @@ export async function handleTaskNudge({ store, id, prompt, agents, createMessage
|
|
|
343
345
|
},
|
|
344
346
|
}
|
|
345
347
|
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Handle listing or creating snapshots via dsh-time-machine (#377).
|
|
351
|
+
*/
|
|
352
|
+
export async function handleTaskSnapshot({ ctx, store, id, method, body, config, env, cwdOf, logger }) {
|
|
353
|
+
const task = store.getTask(id)
|
|
354
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
355
|
+
|
|
356
|
+
if (method === 'GET') {
|
|
357
|
+
const available = isTimeMachineAvailable(ctx)
|
|
358
|
+
const snapshots = available ? await listTaskSnapshots({ ctx, task }) : []
|
|
359
|
+
return {
|
|
360
|
+
status: 200,
|
|
361
|
+
body: {
|
|
362
|
+
available,
|
|
363
|
+
inceptionSnapshotId: task.inceptionSnapshotId || '',
|
|
364
|
+
snapshots,
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (method === 'POST') {
|
|
370
|
+
const label = typeof body?.label === 'string' ? body.label.trim() : ''
|
|
371
|
+
const res = await createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger })
|
|
372
|
+
if (!res.available) {
|
|
373
|
+
return {
|
|
374
|
+
status: 503,
|
|
375
|
+
body: {
|
|
376
|
+
error: 'time-machine-unavailable',
|
|
377
|
+
message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!res.created) {
|
|
382
|
+
return { status: 500, body: { error: 'snapshot-failed', message: res.error } }
|
|
383
|
+
}
|
|
384
|
+
return { status: 201, body: { success: true, snapshot: res.snapshot } }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return { status: 405, body: { error: 'method-not-allowed' } }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Handle rolling back task worktree to inception or specified snapshot (#377).
|
|
392
|
+
*/
|
|
393
|
+
export async function handleTaskRollbackSnapshot({ ctx, store, id, body, config, env, cwdOf, logger, eventHub }) {
|
|
394
|
+
const task = store.getTask(id)
|
|
395
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
396
|
+
|
|
397
|
+
const snapshotId = typeof body?.snapshotId === 'string' ? body.snapshotId.trim() : task.inceptionSnapshotId
|
|
398
|
+
const res = await rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger })
|
|
399
|
+
if (!res.available) {
|
|
400
|
+
return { status: 503, body: { error: 'time-machine-unavailable', message: res.message } }
|
|
401
|
+
}
|
|
402
|
+
if (!res.rolledBack) {
|
|
403
|
+
return { status: 400, body: { error: res.error, message: res.message } }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
407
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'rollback', snapshotId: res.snapshotId })
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
status: 200,
|
|
412
|
+
body: {
|
|
413
|
+
success: true,
|
|
414
|
+
snapshotId: res.snapshotId,
|
|
415
|
+
result: res.result,
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
package/lib/routes/task.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseBody } from '../markdown.js'
|
|
2
2
|
import { columnsOf, wipLimitField } from '../config.js'
|
|
3
3
|
import { withPlan } from './board.js'
|
|
4
|
+
import { checkMoveAllowed, validateDependencies, findUnblockedDependents } from '../dependencies.js'
|
|
4
5
|
|
|
5
6
|
export function parseTaskPath(pathname) {
|
|
6
7
|
const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
|
|
@@ -23,6 +24,17 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
|
|
|
23
24
|
if (!allowed.includes(column)) {
|
|
24
25
|
return { error: 'invalid-column', status: 400 }
|
|
25
26
|
}
|
|
27
|
+
if (column === 'in-progress' && before.column !== 'in-progress') {
|
|
28
|
+
const moveCheck = checkMoveAllowed(before, column, store)
|
|
29
|
+
if (!moveCheck.allowed) {
|
|
30
|
+
return {
|
|
31
|
+
error: 'task-blocked',
|
|
32
|
+
status: 400,
|
|
33
|
+
message: moveCheck.reason,
|
|
34
|
+
pendingBlockers: moveCheck.pendingBlockers,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
26
38
|
}
|
|
27
39
|
const task = store.moveTask(id, { column, beforeId, afterId })
|
|
28
40
|
if (task.column !== before.column) {
|
|
@@ -32,7 +44,10 @@ export function applyMove({ store, config, id, column, beforeId, afterId, source
|
|
|
32
44
|
}
|
|
33
45
|
const limit = wipLimitField(config, task.column)
|
|
34
46
|
const count = store.countInColumn(task.board, task.column)
|
|
35
|
-
|
|
47
|
+
const unblocked = (task.column === 'done' && before.column !== 'done')
|
|
48
|
+
? findUnblockedDependents(id, store, () => store.listTasks({ board: task.board }))
|
|
49
|
+
: []
|
|
50
|
+
return { task, overLimit: limit !== undefined && count > limit, unblocked }
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
/** Create local board task (not linked to Gitea). */
|
|
@@ -96,6 +111,17 @@ export function updateTask({ store, id, input }) {
|
|
|
96
111
|
const val = input.forge.trim().toLowerCase()
|
|
97
112
|
if (val === 'github' || val === 'gitea') patch.forge = val
|
|
98
113
|
}
|
|
114
|
+
if (Array.isArray(input?.blockedBy)) {
|
|
115
|
+
const newBlockedBy = input.blockedBy.filter((b) => typeof b === 'string' && b.trim() !== '')
|
|
116
|
+
const check = validateDependencies(id, newBlockedBy, store)
|
|
117
|
+
if (!check.valid) {
|
|
118
|
+
return { error: 'circular-dependency', status: 400, message: check.error }
|
|
119
|
+
}
|
|
120
|
+
patch.blockedBy = newBlockedBy
|
|
121
|
+
}
|
|
122
|
+
if (typeof input?.inceptionSnapshotId === 'string') {
|
|
123
|
+
patch.inceptionSnapshotId = input.inceptionSnapshotId.trim()
|
|
124
|
+
}
|
|
99
125
|
return { task: store.updateTask(id, patch) }
|
|
100
126
|
}
|
|
101
127
|
|
package/lib/routes.js
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
handleToggleChecklistItem,
|
|
31
31
|
} from '../routes.js'
|
|
32
32
|
import { verifyAndRecordGate } from '../verification.js'
|
|
33
|
+
import { handleTaskSnapshot, handleTaskRollbackSnapshot } from '../routes.js'
|
|
34
|
+
import { createInceptionSnapshot } from '../time-machine.js'
|
|
33
35
|
import { runTask, queueTask, unqueueTask, resolveModel, buildStartMessage, PERMISSION_RANKS } from '../launcher.js'
|
|
34
36
|
import { dispatchMove, stopWork, MOVE_DETAIL, STOP_DETAIL } from '../commands.js'
|
|
35
37
|
import { checkWorktreeDirty } from '../worktree.js'
|
|
@@ -257,6 +259,25 @@ export function createTaskRouteHandler(deps) {
|
|
|
257
259
|
out.dirtyFiles = dirty.files
|
|
258
260
|
}
|
|
259
261
|
}
|
|
262
|
+
if (out.task.column === 'in-progress' && was?.column !== 'in-progress') {
|
|
263
|
+
const cfg = getConfig()
|
|
264
|
+
if (cfg?.timeMachineEnabled !== false && cfg?.timeMachineAutoSnapshot !== false) {
|
|
265
|
+
createInceptionSnapshot({
|
|
266
|
+
ctx,
|
|
267
|
+
task: out.task,
|
|
268
|
+
config: cfg,
|
|
269
|
+
env: process.env,
|
|
270
|
+
cwdOf: () => rootOf(cfg, () => process.cwd()).path,
|
|
271
|
+
store,
|
|
272
|
+
logger: ctx.logger,
|
|
273
|
+
}).catch(() => {})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(out.unblocked) && out.unblocked.length > 0) {
|
|
277
|
+
for (const dep of out.unblocked) {
|
|
278
|
+
eventHub.broadcast('task:change', { taskId: dep.id, action: 'unblocked', task: dep })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
260
281
|
}
|
|
261
282
|
reply(res, out)
|
|
262
283
|
return
|
|
@@ -400,6 +421,24 @@ export function createTaskRouteHandler(deps) {
|
|
|
400
421
|
writeJson(res, resObj.status, resObj.body)
|
|
401
422
|
return
|
|
402
423
|
}
|
|
424
|
+
if ((req.method === 'GET' || req.method === 'POST') && action === 'snapshot') {
|
|
425
|
+
const cfg = getConfig()
|
|
426
|
+
const resObj = await handleTaskSnapshot({
|
|
427
|
+
ctx, store, id, method: req.method, body: input, config: cfg,
|
|
428
|
+
env: process.env, cwdOf: () => rootOf(cfg, () => process.cwd()).path, logger: ctx.logger,
|
|
429
|
+
})
|
|
430
|
+
writeJson(res, resObj.status, resObj.body)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (req.method === 'POST' && action === 'rollback-snapshot') {
|
|
434
|
+
const cfg = getConfig()
|
|
435
|
+
const resObj = await handleTaskRollbackSnapshot({
|
|
436
|
+
ctx, store, id, body: input, config: cfg,
|
|
437
|
+
env: process.env, cwdOf: () => rootOf(cfg, () => process.cwd()).path, logger: ctx.logger, eventHub,
|
|
438
|
+
})
|
|
439
|
+
writeJson(res, resObj.status, resObj.body)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
403
442
|
if (req.method === 'POST' && action === 'resume') {
|
|
404
443
|
watchdog?.resumeTask?.({ store, taskId: id, eventHub })
|
|
405
444
|
const task = store.getTask(id)
|
package/lib/store.js
CHANGED
|
@@ -79,6 +79,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
79
79
|
stalled INTEGER NOT NULL DEFAULT 0,
|
|
80
80
|
stalledReason TEXT NOT NULL DEFAULT '',
|
|
81
81
|
stalledAt INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
blockedBy TEXT NOT NULL DEFAULT '[]',
|
|
83
|
+
inceptionSnapshotId TEXT NOT NULL DEFAULT '',
|
|
82
84
|
updatedAt INTEGER NOT NULL
|
|
83
85
|
);
|
|
84
86
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -114,6 +116,7 @@ const WRITABLE = [
|
|
|
114
116
|
'verificationCommand', 'verificationStatus',
|
|
115
117
|
'costMetrics', 'costLimitExceeded',
|
|
116
118
|
'stalled', 'stalledReason', 'stalledAt',
|
|
119
|
+
'blockedBy', 'inceptionSnapshotId',
|
|
117
120
|
]
|
|
118
121
|
|
|
119
122
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
@@ -121,7 +124,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workfl
|
|
|
121
124
|
|
|
122
125
|
function rowToTask(row) {
|
|
123
126
|
if (row === undefined) return undefined
|
|
124
|
-
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, ...rest } = row
|
|
127
|
+
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, ...rest } = row
|
|
125
128
|
let parsedChecklist = []
|
|
126
129
|
try {
|
|
127
130
|
const value = JSON.parse(checklist ?? '[]')
|
|
@@ -177,6 +180,13 @@ function rowToTask(row) {
|
|
|
177
180
|
return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
|
|
178
181
|
})(),
|
|
179
182
|
costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
|
|
183
|
+
blockedBy: (() => {
|
|
184
|
+
try {
|
|
185
|
+
const val = JSON.parse(blockedBy ?? '[]')
|
|
186
|
+
return Array.isArray(val) ? val.filter((x) => typeof x === 'string') : []
|
|
187
|
+
} catch { return [] }
|
|
188
|
+
})(),
|
|
189
|
+
inceptionSnapshotId: typeof inceptionSnapshotId === 'string' ? inceptionSnapshotId : (typeof rest.inceptionSnapshotId === 'string' ? rest.inceptionSnapshotId : ''),
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
192
|
|
|
@@ -272,6 +282,8 @@ export function openStore({ dir }) {
|
|
|
272
282
|
['stalled', 'INTEGER NOT NULL DEFAULT 0'],
|
|
273
283
|
['stalledReason', "TEXT NOT NULL DEFAULT ''"],
|
|
274
284
|
['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
285
|
+
['blockedBy', "TEXT NOT NULL DEFAULT '[]'"],
|
|
286
|
+
['inceptionSnapshotId', "TEXT NOT NULL DEFAULT ''"],
|
|
275
287
|
]) {
|
|
276
288
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
277
289
|
}
|
|
@@ -614,6 +626,8 @@ export function openStore({ dir }) {
|
|
|
614
626
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
615
627
|
else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
|
|
616
628
|
else if (key === 'parentId') values.push(String(patch[key] ?? ''))
|
|
629
|
+
else if (key === 'blockedBy') values.push(Array.isArray(patch[key]) ? JSON.stringify(patch[key]) : (typeof patch[key] === 'string' ? patch[key] : '[]'))
|
|
630
|
+
else if (key === 'inceptionSnapshotId') values.push(String(patch[key] ?? ''))
|
|
617
631
|
else values.push(patch[key] ?? null)
|
|
618
632
|
}
|
|
619
633
|
if (fields.length > 0) {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Integration service for @goodandready/dsh-time-machine snapshot and rollback capabilities.
|
|
2
|
+
// Provides automatic inception snapshots on task start and one-click rollback to clean state.
|
|
3
|
+
// Degrades gracefully when @goodandready/dsh-time-machine plugin is not installed in DSH profile.
|
|
4
|
+
|
|
5
|
+
import { resolveTaskWorktreePath } from './worktree.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether timeMachineEngine is available in the Cordis context.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} ctx Cordis context or harness
|
|
11
|
+
* @returns {boolean} true if time machine engine is available
|
|
12
|
+
*/
|
|
13
|
+
export function isTimeMachineAvailable(ctx) {
|
|
14
|
+
if (!ctx || typeof ctx !== 'object') return false
|
|
15
|
+
const engine = ctx.timeMachineEngine || (typeof ctx.get === 'function' ? (ctx.get('timeMachineEngine') || ctx.get('timeMachine')) : null)
|
|
16
|
+
return !!(engine && (typeof engine.createSnapshot === 'function' || typeof engine.rollbackSnapshot === 'function' || typeof engine.listSnapshots === 'function'))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the timeMachineEngine instance from context.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} ctx Cordis context
|
|
23
|
+
* @returns {object|null}
|
|
24
|
+
*/
|
|
25
|
+
export function getTimeMachineEngine(ctx) {
|
|
26
|
+
if (!ctx || typeof ctx !== 'object') return null
|
|
27
|
+
const engine = ctx.timeMachineEngine || (typeof ctx.get === 'function' ? (ctx.get('timeMachineEngine') || ctx.get('timeMachine')) : null)
|
|
28
|
+
if (engine && (typeof engine.createSnapshot === 'function' || typeof engine.rollbackSnapshot === 'function' || typeof engine.listSnapshots === 'function')) {
|
|
29
|
+
return engine
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the directory path to snapshot for a task.
|
|
36
|
+
*
|
|
37
|
+
* @param {{task: object, config?: object, env?: object, cwdOf?: Function}} options
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSnapshotDir({ task, config, env, cwdOf }) {
|
|
41
|
+
if (task?.worktree && typeof task.worktree === 'string' && task.worktree.trim() !== '') {
|
|
42
|
+
return task.worktree.trim()
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const resolved = resolveTaskWorktreePath({ task, config, env, cwdOf: cwdOf || (() => process.cwd()) })
|
|
46
|
+
if (resolved) return resolved
|
|
47
|
+
} catch {
|
|
48
|
+
/* fallback to cwd */
|
|
49
|
+
}
|
|
50
|
+
return typeof cwdOf === 'function' ? cwdOf() : process.cwd()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Automatically create inception snapshot when task moves to in-progress.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ctx: object, task: object, config?: object, env?: object, cwdOf?: Function, store?: object, logger?: object}} options
|
|
57
|
+
* @returns {Promise<{available: boolean, created: boolean, snapshotId?: string, alreadyExists?: boolean, snapshot?: object, error?: string}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function createInceptionSnapshot({ ctx, task, config, env, cwdOf, store, logger }) {
|
|
60
|
+
const engine = getTimeMachineEngine(ctx)
|
|
61
|
+
if (!engine) {
|
|
62
|
+
return { available: false, created: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (task?.inceptionSnapshotId && typeof task.inceptionSnapshotId === 'string' && task.inceptionSnapshotId.trim() !== '') {
|
|
66
|
+
return {
|
|
67
|
+
available: true,
|
|
68
|
+
created: false,
|
|
69
|
+
alreadyExists: true,
|
|
70
|
+
snapshotId: task.inceptionSnapshotId.trim(),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
|
|
75
|
+
const label = `Inception: ${task?.id || 'task'} (${task?.title || 'untitled'})`
|
|
76
|
+
const sessionId = `task-${task?.id || 'default'}`
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const snapshot = await engine.createSnapshot(label, {
|
|
80
|
+
sessionId,
|
|
81
|
+
cwd: targetDir,
|
|
82
|
+
skipIfNoChanges: false,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (snapshot?.id && store && typeof store.updateTask === 'function' && task?.id) {
|
|
86
|
+
store.updateTask(task.id, { inceptionSnapshotId: snapshot.id })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
available: true,
|
|
91
|
+
created: true,
|
|
92
|
+
snapshotId: snapshot?.id,
|
|
93
|
+
snapshot,
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger?.warn?.('[dsh-kanban] Failed to create inception snapshot:', err?.message || err)
|
|
97
|
+
return {
|
|
98
|
+
available: true,
|
|
99
|
+
created: false,
|
|
100
|
+
error: err?.message || String(err),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Manually create a checkpoint snapshot for a task.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ctx: object, task: object, label?: string, config?: object, env?: object, cwdOf?: Function, logger?: object}} options
|
|
109
|
+
* @returns {Promise<{available: boolean, created: boolean, snapshot?: object, error?: string}>}
|
|
110
|
+
*/
|
|
111
|
+
export async function createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger }) {
|
|
112
|
+
const engine = getTimeMachineEngine(ctx)
|
|
113
|
+
if (!engine) {
|
|
114
|
+
return { available: false, created: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
|
|
118
|
+
const snapshotLabel = `Checkpoint: ${label || 'manual'} (${task?.id || 'task'})`
|
|
119
|
+
const sessionId = `task-${task?.id || 'default'}`
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const snapshot = await engine.createSnapshot(snapshotLabel, {
|
|
123
|
+
sessionId,
|
|
124
|
+
cwd: targetDir,
|
|
125
|
+
skipIfNoChanges: false,
|
|
126
|
+
})
|
|
127
|
+
return {
|
|
128
|
+
available: true,
|
|
129
|
+
created: true,
|
|
130
|
+
snapshot,
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger?.warn?.('[dsh-kanban] Failed to create checkpoint snapshot:', err?.message || err)
|
|
134
|
+
return {
|
|
135
|
+
available: true,
|
|
136
|
+
created: false,
|
|
137
|
+
error: err?.message || String(err),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Roll back task files to a snapshot (default: inception snapshot).
|
|
144
|
+
*
|
|
145
|
+
* @param {{ctx: object, task: object, snapshotId?: string, config?: object, env?: object, cwdOf?: Function, logger?: object}} options
|
|
146
|
+
* @returns {Promise<{available: boolean, rolledBack: boolean, snapshotId?: string, result?: object, error?: string, message?: string}>}
|
|
147
|
+
*/
|
|
148
|
+
export async function rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger }) {
|
|
149
|
+
const engine = getTimeMachineEngine(ctx)
|
|
150
|
+
if (!engine) {
|
|
151
|
+
return {
|
|
152
|
+
available: false,
|
|
153
|
+
rolledBack: false,
|
|
154
|
+
error: 'time-machine-unavailable',
|
|
155
|
+
message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const targetId = snapshotId || task?.inceptionSnapshotId
|
|
160
|
+
if (!targetId || typeof targetId !== 'string' || targetId.trim() === '') {
|
|
161
|
+
return {
|
|
162
|
+
available: true,
|
|
163
|
+
rolledBack: false,
|
|
164
|
+
error: 'no-snapshot-id',
|
|
165
|
+
message: 'No snapshot ID specified and task has no inception snapshot.',
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const result = await engine.rollbackSnapshot(String(targetId).trim(), {
|
|
173
|
+
confirm: true,
|
|
174
|
+
cwd: targetDir,
|
|
175
|
+
})
|
|
176
|
+
return {
|
|
177
|
+
available: true,
|
|
178
|
+
rolledBack: true,
|
|
179
|
+
snapshotId: targetId,
|
|
180
|
+
result,
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
logger?.error?.('[dsh-kanban] Failed to rollback snapshot:', err?.message || err)
|
|
184
|
+
return {
|
|
185
|
+
available: true,
|
|
186
|
+
rolledBack: false,
|
|
187
|
+
error: 'rollback-failed',
|
|
188
|
+
message: err?.message || String(err),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* List snapshots for a task.
|
|
195
|
+
*
|
|
196
|
+
* @param {{ctx: object, task: object}} options
|
|
197
|
+
* @returns {Promise<Array<object>>}
|
|
198
|
+
*/
|
|
199
|
+
export async function listTaskSnapshots({ ctx, task }) {
|
|
200
|
+
const engine = getTimeMachineEngine(ctx)
|
|
201
|
+
if (!engine || !task?.id) return []
|
|
202
|
+
|
|
203
|
+
const sessionId = `task-${task.id}`
|
|
204
|
+
try {
|
|
205
|
+
if (typeof engine.listSnapshots === 'function') {
|
|
206
|
+
const list = engine.listSnapshots(sessionId)
|
|
207
|
+
return Array.isArray(list) ? list : []
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
/* ignore error */
|
|
211
|
+
}
|
|
212
|
+
return []
|
|
213
|
+
}
|
|
214
|
+
|