@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,204 @@
|
|
|
1
|
+
import { computeNextRun } from '../cron.js'
|
|
2
|
+
import { PERMISSION_RANKS, resolveModel, runTask } from '../launcher.js'
|
|
3
|
+
import { createOutbox, planOutbound } from '../outbound.js'
|
|
4
|
+
import { createSyncState, syncAll, intakeAll, archiveOverdue } from '../sync.js'
|
|
5
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
6
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
export function registerKanbanTimers(ctx, deps) {
|
|
10
|
+
const {
|
|
11
|
+
store,
|
|
12
|
+
getConfig,
|
|
13
|
+
eventHub,
|
|
14
|
+
syncPower,
|
|
15
|
+
git,
|
|
16
|
+
gitea,
|
|
17
|
+
github,
|
|
18
|
+
rearmers,
|
|
19
|
+
watchdog,
|
|
20
|
+
} = deps
|
|
21
|
+
|
|
22
|
+
ctx.effect(() => {
|
|
23
|
+
let cronTimer
|
|
24
|
+
const armCron = () => {
|
|
25
|
+
clearInterval(cronTimer)
|
|
26
|
+
cronTimer = undefined
|
|
27
|
+
if (getConfig().cronEnabled !== true) return
|
|
28
|
+
cronTimer = setInterval(async () => {
|
|
29
|
+
try {
|
|
30
|
+
if (!store) return
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
const dueTasks = store.listDueTasks(now)
|
|
33
|
+
for (const task of dueTasks) {
|
|
34
|
+
if (task.column === 'in-progress') {
|
|
35
|
+
const next = computeNextRun(task.cron, now)
|
|
36
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (getConfig().permissionGateEnabled) {
|
|
40
|
+
const perm = task.permission || 'read-only'
|
|
41
|
+
const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
|
|
42
|
+
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
|
|
43
|
+
const next = computeNextRun(task.cron, now)
|
|
44
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
45
|
+
store.addTransition({
|
|
46
|
+
taskId: task.id,
|
|
47
|
+
fromCol: task.column,
|
|
48
|
+
toCol: task.column,
|
|
49
|
+
source: 'tool',
|
|
50
|
+
detail: 'cron: run rejected (elevated permission required)',
|
|
51
|
+
})
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const picked = resolveModel({
|
|
56
|
+
requested: { model: task.model, provider: task.provider },
|
|
57
|
+
fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
58
|
+
})
|
|
59
|
+
if (picked.model) {
|
|
60
|
+
await runTask({
|
|
61
|
+
agents: ctx.agents,
|
|
62
|
+
store,
|
|
63
|
+
task,
|
|
64
|
+
config: getConfig(),
|
|
65
|
+
provider: picked.provider,
|
|
66
|
+
model: picked.model,
|
|
67
|
+
mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
|
|
68
|
+
createMessage: createUserMessage,
|
|
69
|
+
logger: ctx.logger,
|
|
70
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
71
|
+
claimAs: 'cron',
|
|
72
|
+
})
|
|
73
|
+
syncPower()
|
|
74
|
+
}
|
|
75
|
+
const next = computeNextRun(task.cron, now)
|
|
76
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
|
|
80
|
+
}
|
|
81
|
+
}, 30_000)
|
|
82
|
+
if (typeof cronTimer.unref === 'function') cronTimer.unref()
|
|
83
|
+
}
|
|
84
|
+
armCron()
|
|
85
|
+
rearmers.add(armCron)
|
|
86
|
+
return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
|
|
87
|
+
}, 'dsh-kanban: cron scheduler')
|
|
88
|
+
|
|
89
|
+
const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
|
|
90
|
+
|
|
91
|
+
const queueOutbound = (taskId, column) => {
|
|
92
|
+
if (getConfig().pushToGitea !== true) return
|
|
93
|
+
const task = store.getTask(taskId)
|
|
94
|
+
if (task === undefined) return
|
|
95
|
+
const ops = planOutbound(task, column, getConfig())
|
|
96
|
+
if (ops.length > 0) outbox.push(taskId, ops)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let discoveredOwner
|
|
100
|
+
const defaultOwner = async () => {
|
|
101
|
+
const named = String(getConfig().giteaOwner ?? '').trim()
|
|
102
|
+
if (named !== '') return named
|
|
103
|
+
for (const task of store.listWatchable()) {
|
|
104
|
+
if (typeof task.owner === 'string' && task.owner !== '') return task.owner
|
|
105
|
+
}
|
|
106
|
+
if (discoveredOwner !== undefined) return discoveredOwner
|
|
107
|
+
const orgs = await gitea.listOrgs()
|
|
108
|
+
if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const syncState = createSyncState()
|
|
113
|
+
|
|
114
|
+
let syncing = false
|
|
115
|
+
const runSync = async () => {
|
|
116
|
+
if (syncing) return { skipped: true }
|
|
117
|
+
syncing = true
|
|
118
|
+
syncState.started()
|
|
119
|
+
try {
|
|
120
|
+
const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
|
|
121
|
+
const providersToSync = []
|
|
122
|
+
if (mode === 'github') {
|
|
123
|
+
providersToSync.push(github)
|
|
124
|
+
} else if (mode === 'gitea') {
|
|
125
|
+
providersToSync.push(gitea)
|
|
126
|
+
} else {
|
|
127
|
+
if (await gitea.isConfigured()) providersToSync.push(gitea)
|
|
128
|
+
if (await github.isConfigured()) providersToSync.push(github)
|
|
129
|
+
if (providersToSync.length === 0) providersToSync.push(gitea)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let taken = { added: 0, skipped: 0, failed: 0 }
|
|
133
|
+
for (const p of providersToSync) {
|
|
134
|
+
const owner = p === github
|
|
135
|
+
? (String(getConfig()?.githubOwner ?? '').trim() || await p.me().catch(() => ''))
|
|
136
|
+
: await defaultOwner()
|
|
137
|
+
const res = await intakeAll({
|
|
138
|
+
gitea: p, store, config: getConfig(), owner, logger: ctx.logger,
|
|
139
|
+
})
|
|
140
|
+
if (res && typeof res.added === 'number') {
|
|
141
|
+
taken.added = (taken.added || 0) + res.added
|
|
142
|
+
taken.skipped = (taken.skipped || 0) + (res.skipped || 0)
|
|
143
|
+
taken.failed = (taken.failed || 0) + (res.failed || 0)
|
|
144
|
+
}
|
|
145
|
+
if (res?.problem) taken.problem = res.problem
|
|
146
|
+
}
|
|
147
|
+
const out = await syncAll({ gitea: git, store, logger: ctx.logger })
|
|
148
|
+
const tidied = archiveOverdue({ store, config: getConfig() })
|
|
149
|
+
const pushed = await outbox.flush()
|
|
150
|
+
syncState.finished(out.problem === undefined && taken.problem !== undefined
|
|
151
|
+
? { ...out, problem: taken.problem }
|
|
152
|
+
: out)
|
|
153
|
+
return { ...out, pushed, taken, tidied }
|
|
154
|
+
} catch (error) {
|
|
155
|
+
syncState.failedToStart('sync', error?.message)
|
|
156
|
+
throw error
|
|
157
|
+
} finally {
|
|
158
|
+
syncing = false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
ctx.effect(() => {
|
|
163
|
+
let timer
|
|
164
|
+
const arm = () => {
|
|
165
|
+
const seconds = Number(getConfig().syncIntervalSec) || 0
|
|
166
|
+
clearInterval(timer)
|
|
167
|
+
timer = undefined
|
|
168
|
+
if (seconds <= 0) return
|
|
169
|
+
timer = setInterval(() => { runSync().catch(() => {}) }, Math.max(seconds, 15) * 1000)
|
|
170
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
171
|
+
}
|
|
172
|
+
arm()
|
|
173
|
+
rearmers.add(arm)
|
|
174
|
+
return () => { rearmers.delete(arm); clearInterval(timer) }
|
|
175
|
+
}, 'dsh-kanban: sync timer')
|
|
176
|
+
|
|
177
|
+
ctx.effect(() => {
|
|
178
|
+
let watchdogTimer
|
|
179
|
+
const armWatchdog = () => {
|
|
180
|
+
clearInterval(watchdogTimer)
|
|
181
|
+
watchdogTimer = undefined
|
|
182
|
+
if (getConfig().watchdogEnabled === false || !watchdog) return
|
|
183
|
+
watchdogTimer = setInterval(() => {
|
|
184
|
+
try {
|
|
185
|
+
watchdog.checkTasks({
|
|
186
|
+
store,
|
|
187
|
+
agents: ctx.agents,
|
|
188
|
+
config: getConfig(),
|
|
189
|
+
eventHub,
|
|
190
|
+
logger: ctx.logger,
|
|
191
|
+
})
|
|
192
|
+
} catch (err) {
|
|
193
|
+
ctx.logger?.warn?.(`dsh-kanban: watchdog check error: ${err?.message}`)
|
|
194
|
+
}
|
|
195
|
+
}, 15000)
|
|
196
|
+
if (typeof watchdogTimer.unref === 'function') watchdogTimer.unref()
|
|
197
|
+
}
|
|
198
|
+
armWatchdog()
|
|
199
|
+
rearmers.add(armWatchdog)
|
|
200
|
+
return () => { rearmers.delete(armWatchdog); clearInterval(watchdogTimer) }
|
|
201
|
+
}, 'dsh-kanban: watchdog timer')
|
|
202
|
+
|
|
203
|
+
return { syncState, runSync, defaultOwner, queueOutbound }
|
|
204
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -76,6 +76,9 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
76
76
|
forge TEXT NOT NULL DEFAULT '',
|
|
77
77
|
permissionConfirmedTier TEXT NOT NULL DEFAULT '',
|
|
78
78
|
queuedRequest TEXT NOT NULL DEFAULT '',
|
|
79
|
+
stalled INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
stalledReason TEXT NOT NULL DEFAULT '',
|
|
81
|
+
stalledAt INTEGER NOT NULL DEFAULT 0,
|
|
79
82
|
updatedAt INTEGER NOT NULL
|
|
80
83
|
);
|
|
81
84
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -110,14 +113,15 @@ const WRITABLE = [
|
|
|
110
113
|
'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
|
|
111
114
|
'verificationCommand', 'verificationStatus',
|
|
112
115
|
'costMetrics', 'costLimitExceeded',
|
|
116
|
+
'stalled', 'stalledReason', 'stalledAt',
|
|
113
117
|
]
|
|
114
118
|
|
|
115
119
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
116
|
-
const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow'])
|
|
120
|
+
const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow', 'watchdog'])
|
|
117
121
|
|
|
118
122
|
function rowToTask(row) {
|
|
119
123
|
if (row === undefined) return undefined
|
|
120
|
-
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
|
|
124
|
+
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, ...rest } = row
|
|
121
125
|
let parsedChecklist = []
|
|
122
126
|
try {
|
|
123
127
|
const value = JSON.parse(checklist ?? '[]')
|
|
@@ -265,6 +269,9 @@ export function openStore({ dir }) {
|
|
|
265
269
|
['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
266
270
|
['costMetrics', "TEXT NOT NULL DEFAULT ''"],
|
|
267
271
|
['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
|
|
272
|
+
['stalled', 'INTEGER NOT NULL DEFAULT 0'],
|
|
273
|
+
['stalledReason', "TEXT NOT NULL DEFAULT ''"],
|
|
274
|
+
['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
268
275
|
]) {
|
|
269
276
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
270
277
|
}
|
package/lib/watchdog.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog timer and Circuit Breaker service for agent tasks.
|
|
3
|
+
*
|
|
4
|
+
* Automatically monitors tasks in 'in-progress'.
|
|
5
|
+
* If a task is inactive longer than watchdogStallTimeoutMin or incurs
|
|
6
|
+
* watchdogMaxConsecutiveErrors consecutive execution errors,
|
|
7
|
+
* the circuit breaker trips:
|
|
8
|
+
* 1. The agent session is aborted/paused to avoid burning quota.
|
|
9
|
+
* 2. The task is marked as stalled with diagnostic detail.
|
|
10
|
+
* 3. A diagnostic transition is added to the task journal.
|
|
11
|
+
* 4. A 'task:change' event is broadcast via eventHub.
|
|
12
|
+
*
|
|
13
|
+
* Tasks can be resumed manually via resumeTask or /task/:id/resume.
|
|
14
|
+
*/
|
|
15
|
+
export class WatchdogService {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.consecutiveErrors = new Map()
|
|
18
|
+
this.lastActive = new Map()
|
|
19
|
+
this.lastError = new Map()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
recordActivity(taskId, _type = 'activity') {
|
|
23
|
+
if (!taskId) return
|
|
24
|
+
this.lastActive.set(taskId, Date.now())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
recordError(taskId, error) {
|
|
28
|
+
if (!taskId) return
|
|
29
|
+
const count = (this.consecutiveErrors.get(taskId) || 0) + 1
|
|
30
|
+
this.consecutiveErrors.set(taskId, count)
|
|
31
|
+
const msg = typeof error === 'string' ? error : (error?.message || error?.name || 'Execution error')
|
|
32
|
+
this.lastError.set(taskId, msg)
|
|
33
|
+
this.lastActive.set(taskId, Date.now())
|
|
34
|
+
return count
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clearError(taskId) {
|
|
38
|
+
if (!taskId) return
|
|
39
|
+
this.consecutiveErrors.delete(taskId)
|
|
40
|
+
this.lastError.delete(taskId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clearTask(taskId) {
|
|
44
|
+
if (!taskId) return
|
|
45
|
+
this.consecutiveErrors.delete(taskId)
|
|
46
|
+
this.lastError.delete(taskId)
|
|
47
|
+
this.lastActive.delete(taskId)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
checkTask(task, options = {}) {
|
|
51
|
+
const { config = {}, now = Date.now() } = options
|
|
52
|
+
if (config.watchdogEnabled === false) return { shouldTrip: false }
|
|
53
|
+
if (!task || task.column !== 'in-progress' || task.stalled) return { shouldTrip: false }
|
|
54
|
+
|
|
55
|
+
const timeoutMs = (Number(config.watchdogStallTimeoutMin) || 15) * 60 * 1000
|
|
56
|
+
const maxErrors = Number(config.watchdogMaxConsecutiveErrors) || 5
|
|
57
|
+
|
|
58
|
+
// Check consecutive errors
|
|
59
|
+
const errCount = this.consecutiveErrors.get(task.id) || 0
|
|
60
|
+
if (errCount >= maxErrors) {
|
|
61
|
+
const lastErr = this.lastError.get(task.id) || 'ToolExecutionError'
|
|
62
|
+
return {
|
|
63
|
+
shouldTrip: true,
|
|
64
|
+
reason: `Circuit breaker tripped: ${errCount} consecutive execution errors (${lastErr})`,
|
|
65
|
+
errCount,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check inactivity timeout
|
|
70
|
+
const last = this.lastActive.get(task.id) || task.updatedAt || task.columnAt || task.createdAt || now
|
|
71
|
+
const idleMs = now - last
|
|
72
|
+
if (idleMs >= timeoutMs) {
|
|
73
|
+
const idleMin = Math.round(idleMs / 60000)
|
|
74
|
+
return {
|
|
75
|
+
shouldTrip: true,
|
|
76
|
+
reason: `Watchdog timeout: task inactive for ${idleMin} minutes (threshold ${config.watchdogStallTimeoutMin || 15}m)`,
|
|
77
|
+
idleMs,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { shouldTrip: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
tripTask(task, reason, options = {}) {
|
|
85
|
+
const { store, agents, eventHub, logger } = options
|
|
86
|
+
if (!task) return
|
|
87
|
+
|
|
88
|
+
// 1. Abort/pause session if running
|
|
89
|
+
if (task.sessionId && agents) {
|
|
90
|
+
try {
|
|
91
|
+
const agent = agents.get?.(task.sessionId)
|
|
92
|
+
if (agent && typeof agent.abort === 'function') {
|
|
93
|
+
agent.abort('watchdog: circuit breaker tripped')
|
|
94
|
+
} else if (agent && typeof agent.pause === 'function') {
|
|
95
|
+
agent.pause()
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger?.warn?.(`dsh-kanban: watchdog failed to abort agent for session ${task.sessionId}: ${err?.message}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Update task in store
|
|
103
|
+
if (store && typeof store.updateTask === 'function') {
|
|
104
|
+
store.updateTask(task.id, {
|
|
105
|
+
stalled: 1,
|
|
106
|
+
stalledReason: reason,
|
|
107
|
+
stalledAt: Date.now(),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// 3. Journal transition
|
|
111
|
+
if (typeof store.addTransition === 'function') {
|
|
112
|
+
store.addTransition({
|
|
113
|
+
taskId: task.id,
|
|
114
|
+
fromCol: task.column,
|
|
115
|
+
toCol: task.column,
|
|
116
|
+
source: 'watchdog',
|
|
117
|
+
detail: reason,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 4. Broadcast event
|
|
123
|
+
eventHub?.broadcast?.('task:change', {
|
|
124
|
+
taskId: task.id,
|
|
125
|
+
action: 'stalled',
|
|
126
|
+
stalled: true,
|
|
127
|
+
reason,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
logger?.warn?.(`dsh-kanban: watchdog tripped for task ${task.id}: ${reason}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
checkTasks(options = {}) {
|
|
134
|
+
const { store, agents, config = {}, eventHub, logger } = options
|
|
135
|
+
if (config.watchdogEnabled === false || !store) return []
|
|
136
|
+
|
|
137
|
+
const tripped = []
|
|
138
|
+
const inProgressTasks = store.listTasks({ column: 'in-progress' })
|
|
139
|
+
const now = Date.now()
|
|
140
|
+
|
|
141
|
+
for (const task of inProgressTasks) {
|
|
142
|
+
const check = this.checkTask(task, { config, now })
|
|
143
|
+
if (check.shouldTrip) {
|
|
144
|
+
this.tripTask(task, check.reason, { store, agents, eventHub, logger })
|
|
145
|
+
tripped.push({ taskId: task.id, reason: check.reason })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return tripped
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resumeTask(options = {}) {
|
|
152
|
+
const { store, taskId, eventHub } = options
|
|
153
|
+
if (!taskId) return
|
|
154
|
+
if (store && typeof store.updateTask === 'function') {
|
|
155
|
+
store.updateTask(taskId, {
|
|
156
|
+
stalled: 0,
|
|
157
|
+
stalledReason: '',
|
|
158
|
+
stalledAt: 0,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
this.clearTask(taskId)
|
|
162
|
+
this.recordActivity(taskId, 'resume')
|
|
163
|
+
eventHub?.broadcast?.('task:change', { taskId, action: 'resumed', stalled: false })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createWatchdogService() {
|
|
168
|
+
return new WatchdogService()
|
|
169
|
+
}
|
package/lib/worktree.js
CHANGED
|
@@ -71,7 +71,32 @@ export function safeRepoKey(task) {
|
|
|
71
71
|
/**
|
|
72
72
|
* Resolve root directory for all managed worktrees.
|
|
73
73
|
*/
|
|
74
|
+
/**
|
|
75
|
+
* Check if worktree directory exists locally or remotely.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} worktreePath
|
|
78
|
+
* @param {Function} [gitRunner]
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
export async function safeWorktreeExists(worktreePath, gitRunner) {
|
|
82
|
+
if (!worktreePath) return false
|
|
83
|
+
if (existsSync(worktreePath)) return true
|
|
84
|
+
if (typeof gitRunner === 'function') {
|
|
85
|
+
try {
|
|
86
|
+
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], worktreePath)
|
|
87
|
+
return res.stdout?.trim() === 'true'
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
|
|
96
|
+
if (config?.remoteMode && config?.remoteRoot) {
|
|
97
|
+
const root = String(config.remoteRoot).trim().replace(/\/+$/, '')
|
|
98
|
+
return `${root}/.worktrees`
|
|
99
|
+
}
|
|
75
100
|
const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
|
|
76
101
|
if (custom !== '') {
|
|
77
102
|
return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
|
|
@@ -105,6 +130,12 @@ export function isPathFenced(targetPath, rootPath) {
|
|
|
105
130
|
}
|
|
106
131
|
|
|
107
132
|
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
133
|
+
if (config?.remoteMode) {
|
|
134
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
135
|
+
const repoKey = safeRepoKey(task)
|
|
136
|
+
const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
|
|
137
|
+
return `${root}/${repoKey}/${taskId}`
|
|
138
|
+
}
|
|
108
139
|
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
109
140
|
const repoKey = safeRepoKey(task)
|
|
110
141
|
const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
|
|
@@ -261,7 +292,8 @@ function isSubmoduleDrift(worktreePath, line) {
|
|
|
261
292
|
* Excludes submodule noise and gitlink drift (#213).
|
|
262
293
|
*/
|
|
263
294
|
export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
|
|
264
|
-
|
|
295
|
+
const exists = await safeWorktreeExists(worktreePath, gitRunner)
|
|
296
|
+
if (!worktreePath || !exists) {
|
|
265
297
|
return { dirty: false, files: [] }
|
|
266
298
|
}
|
|
267
299
|
try {
|
|
@@ -289,7 +321,8 @@ export async function removeTaskWorktree({
|
|
|
289
321
|
gitRunner = defaultGitRunner,
|
|
290
322
|
rootPath,
|
|
291
323
|
}) {
|
|
292
|
-
|
|
324
|
+
const exists = await safeWorktreeExists(worktreePath, gitRunner)
|
|
325
|
+
if (!worktreePath || !exists) {
|
|
293
326
|
return { removed: false, reason: 'not-found' }
|
|
294
327
|
}
|
|
295
328
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.35",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|