@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/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
+
@@ -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
+ }
@@ -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
- return { task, overLimit: limit !== undefined && count > limit }
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
@@ -69,4 +69,6 @@ export {
69
69
  handleToggleChecklistItem,
70
70
  handleCreateTaskPr,
71
71
  handleTaskNudge,
72
+ handleTaskSnapshot,
73
+ handleTaskRollbackSnapshot,
72
74
  } from './routes/task-actions.js'
@@ -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
+