@goodandready/dsh-kanban 0.2.32 → 0.2.34
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 +401 -346
- package/lib/config.js +29 -1
- package/lib/cost-telemetry.js +146 -0
- package/lib/index.js +94 -6
- package/lib/remote-relay.js +149 -0
- package/lib/remote-runner.js +164 -0
- package/lib/routes.js +97 -9
- package/lib/store.js +22 -0
- package/lib/worktree.js +35 -2
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -308,10 +308,23 @@ function withPlan(task, liveOf, config, now, sameSession) {
|
|
|
308
308
|
try {
|
|
309
309
|
if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
|
|
310
310
|
} catch { live = undefined }
|
|
311
|
+
const liveStatus = typeof live === 'object' && live !== null ? live.status : live
|
|
312
|
+
const liveStep = typeof live === 'object' && live !== null
|
|
313
|
+
? (live.step || live.activity || live.stateDetail || '')
|
|
314
|
+
: ''
|
|
315
|
+
|
|
316
|
+
const costMetrics = task.costMetrics || null
|
|
317
|
+
const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
|
|
318
|
+
? costMetrics.costUsd >= Number(config.taskCostLimit)
|
|
319
|
+
: Boolean(task.costLimitExceeded)
|
|
320
|
+
|
|
311
321
|
return {
|
|
312
322
|
...task,
|
|
313
323
|
plan: { items: planItems(plan), progress: planProgress(plan) },
|
|
314
|
-
state: taskState({ task, live }),
|
|
324
|
+
state: taskState({ task, live: liveStatus }),
|
|
325
|
+
liveStep: liveStep || task.liveStep || '',
|
|
326
|
+
costMetrics,
|
|
327
|
+
costLimitExceeded,
|
|
315
328
|
// Queue position calculated dynamically across active tasks in same session.
|
|
316
329
|
queuePos: queuePosition(task, sameSession),
|
|
317
330
|
// Label facets extracted on server for consistent client filtering.
|
|
@@ -344,7 +357,7 @@ function orderForBoard(tasks, columns) {
|
|
|
344
357
|
/**
|
|
345
358
|
* Build board response payload: columns in canonical order, tasks, and limits.
|
|
346
359
|
*/
|
|
347
|
-
export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
|
|
360
|
+
export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
|
|
348
361
|
// Uniform timestamp across snapshot calculation.
|
|
349
362
|
const now = Date.now()
|
|
350
363
|
// Tasks are sorted and grouped on server: repo grouping with active items first.
|
|
@@ -384,6 +397,8 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
384
397
|
facets: facetsOf(tasks),
|
|
385
398
|
// Sync telemetry state attached directly to board snapshot.
|
|
386
399
|
sync: sync ?? { state: 'never' },
|
|
400
|
+
costMeterAvailable: Boolean(costMeterAvailable),
|
|
401
|
+
remoteMode: Boolean(config?.remoteMode),
|
|
387
402
|
}
|
|
388
403
|
}
|
|
389
404
|
|
|
@@ -753,7 +768,7 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
753
768
|
*/
|
|
754
769
|
export async function handleCleanupWorktree({
|
|
755
770
|
store, task, workspaceRegistry, force, cleanupTaskWorktree,
|
|
756
|
-
config, repoDir, cwdOf, env,
|
|
771
|
+
config, repoDir, cwdOf, env, gitRunner,
|
|
757
772
|
}) {
|
|
758
773
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
759
774
|
const out = await cleanupTaskWorktree({
|
|
@@ -765,6 +780,7 @@ export async function handleCleanupWorktree({
|
|
|
765
780
|
repoDir,
|
|
766
781
|
cwdOf,
|
|
767
782
|
env,
|
|
783
|
+
gitRunner,
|
|
768
784
|
})
|
|
769
785
|
const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
|
|
770
786
|
return { status, body: out }
|
|
@@ -786,32 +802,33 @@ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }
|
|
|
786
802
|
/**
|
|
787
803
|
* Discard file changes in task worktree (#371).
|
|
788
804
|
*/
|
|
789
|
-
export async function handleTaskDiscard({ task, file, discardTaskFile }) {
|
|
805
|
+
export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
|
|
790
806
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
791
807
|
if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
|
|
792
808
|
if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
|
|
793
|
-
const result = await discardTaskFile({ worktreePath: task.worktree, file })
|
|
809
|
+
const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
|
|
794
810
|
return { status: result.success ? 200 : 400, body: result }
|
|
795
811
|
}
|
|
796
812
|
|
|
797
813
|
/**
|
|
798
814
|
* Get commit list for task.
|
|
799
815
|
*/
|
|
800
|
-
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
816
|
+
export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
|
|
801
817
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
802
|
-
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
818
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
|
|
803
819
|
return { status: 200, body: { commits } }
|
|
804
820
|
}
|
|
805
821
|
|
|
806
822
|
/**
|
|
807
823
|
* Merge task worktree branch and transition to done.
|
|
808
824
|
*/
|
|
809
|
-
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
825
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
|
|
810
826
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
811
827
|
const mergeResult = await mergeTaskWorktree({
|
|
812
828
|
task,
|
|
813
829
|
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
814
830
|
workspaceRegistry,
|
|
831
|
+
gitRunner,
|
|
815
832
|
})
|
|
816
833
|
if (!mergeResult.success) {
|
|
817
834
|
return {
|
|
@@ -1119,4 +1136,75 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
|
1119
1136
|
} catch (err) {
|
|
1120
1137
|
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
1121
1138
|
}
|
|
1122
|
-
}
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Handle interactive live steering (nudge) for an active task agent (#373).
|
|
1142
|
+
*
|
|
1143
|
+
* @param {{
|
|
1144
|
+
* store: object,
|
|
1145
|
+
* id: string,
|
|
1146
|
+
* prompt: string,
|
|
1147
|
+
* agents?: object,
|
|
1148
|
+
* createMessage?: Function,
|
|
1149
|
+
* eventHub?: object,
|
|
1150
|
+
* now?: number
|
|
1151
|
+
* }} options
|
|
1152
|
+
* @returns {Promise<{ status: number, body: object }>}
|
|
1153
|
+
*/
|
|
1154
|
+
export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
|
|
1155
|
+
if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
|
|
1156
|
+
const task = store.getTask(id)
|
|
1157
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
1158
|
+
|
|
1159
|
+
const text = typeof prompt === 'string' ? prompt.trim() : ''
|
|
1160
|
+
if (!text) return { status: 400, body: { error: 'prompt-required' } }
|
|
1161
|
+
|
|
1162
|
+
const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
1163
|
+
if (!sessionId) return { status: 409, body: { error: 'no-session' } }
|
|
1164
|
+
|
|
1165
|
+
let agent
|
|
1166
|
+
try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
|
|
1167
|
+
if (!agent || typeof agent.followup !== 'function') {
|
|
1168
|
+
return { status: 409, body: { error: 'session-not-live' } }
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const msg = typeof createMessage === 'function'
|
|
1172
|
+
? createMessage({
|
|
1173
|
+
content: [{ type: 'text', text }],
|
|
1174
|
+
source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
|
|
1175
|
+
})
|
|
1176
|
+
: { content: [{ type: 'text', text }] }
|
|
1177
|
+
|
|
1178
|
+
agent.followup(msg)
|
|
1179
|
+
|
|
1180
|
+
// Append user steering comment to task
|
|
1181
|
+
if (typeof store.addComment === 'function') {
|
|
1182
|
+
store.addComment(id, {
|
|
1183
|
+
author: 'user',
|
|
1184
|
+
role: 'user',
|
|
1185
|
+
text: `[Steering Nudge] ${text}`,
|
|
1186
|
+
})
|
|
1187
|
+
} else if (typeof store.addTaskComment === 'function') {
|
|
1188
|
+
store.addTaskComment(id, {
|
|
1189
|
+
author: 'user',
|
|
1190
|
+
role: 'user',
|
|
1191
|
+
text: `[Steering Nudge] ${text}`,
|
|
1192
|
+
})
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Broadcast change via eventHub
|
|
1196
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
1197
|
+
eventHub.broadcast('task:change', { id, col: task.col || task.column })
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return {
|
|
1201
|
+
status: 200,
|
|
1202
|
+
body: {
|
|
1203
|
+
success: true,
|
|
1204
|
+
id,
|
|
1205
|
+
sessionId,
|
|
1206
|
+
prompt: text,
|
|
1207
|
+
nudgedAt: now,
|
|
1208
|
+
},
|
|
1209
|
+
}
|
|
1210
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -109,6 +109,7 @@ const WRITABLE = [
|
|
|
109
109
|
'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
|
|
110
110
|
'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
|
|
111
111
|
'verificationCommand', 'verificationStatus',
|
|
112
|
+
'costMetrics', 'costLimitExceeded',
|
|
112
113
|
]
|
|
113
114
|
|
|
114
115
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
@@ -162,6 +163,16 @@ function rowToTask(row) {
|
|
|
162
163
|
reasoningEffort: rest.reasoningEffort ?? '',
|
|
163
164
|
isEpic: isEpic === 1,
|
|
164
165
|
parentId: parentId ?? '',
|
|
166
|
+
costMetrics: (() => {
|
|
167
|
+
if (typeof rest.costMetrics === 'string' && rest.costMetrics.trim()) {
|
|
168
|
+
try {
|
|
169
|
+
const val = JSON.parse(rest.costMetrics);
|
|
170
|
+
if (val && typeof val === 'object') return val;
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
|
|
174
|
+
})(),
|
|
175
|
+
costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
|
|
165
176
|
}
|
|
166
177
|
}
|
|
167
178
|
|
|
@@ -252,6 +263,8 @@ export function openStore({ dir }) {
|
|
|
252
263
|
['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
|
|
253
264
|
['verificationCommand', "TEXT NOT NULL DEFAULT ''"],
|
|
254
265
|
['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
266
|
+
['costMetrics', "TEXT NOT NULL DEFAULT ''"],
|
|
267
|
+
['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
|
|
255
268
|
]) {
|
|
256
269
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
257
270
|
}
|
|
@@ -653,6 +666,15 @@ export function openStore({ dir }) {
|
|
|
653
666
|
})
|
|
654
667
|
},
|
|
655
668
|
|
|
669
|
+
appendComment(id, comment) {
|
|
670
|
+
if (!comment || typeof comment !== 'object') return undefined
|
|
671
|
+
return this.addComment(id, {
|
|
672
|
+
author: comment.author || 'user',
|
|
673
|
+
role: comment.role || 'user',
|
|
674
|
+
text: comment.text || comment.body || '',
|
|
675
|
+
})
|
|
676
|
+
},
|
|
677
|
+
|
|
656
678
|
updateChecklist(id, items) {
|
|
657
679
|
const task = rowToTask(q.taskById.get(id))
|
|
658
680
|
if (!task) return undefined
|
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.34",
|
|
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",
|