@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,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote DSH server relay client (#390).
|
|
3
|
+
* Proxies board state, task actions, and sessions between PC client and server 111.
|
|
4
|
+
* Zero Cyrillic literals in lib/ according to Issue #366 standard.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether requests should be relayed to remote DSH server.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} config
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function shouldRelay(config) {
|
|
14
|
+
return Boolean(config && config.remoteMode && config.remoteServerUrl)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve target URL on remote DSH server.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} config
|
|
21
|
+
* @param {string} path
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function getRemoteServerUrl(config, path = '') {
|
|
25
|
+
const base = String(config?.remoteServerUrl || 'http://192.168.1.111:3080').trim().replace(/\/+$/, '')
|
|
26
|
+
const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : ''
|
|
27
|
+
return `${base}${cleanPath}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build request headers including DSH auth token and cookies.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} config
|
|
34
|
+
* @param {object} [originalHeaders]
|
|
35
|
+
* @returns {Record<string, string>}
|
|
36
|
+
*/
|
|
37
|
+
export function buildRemoteHeaders(config, originalHeaders = {}) {
|
|
38
|
+
const headers = {
|
|
39
|
+
'Accept': 'application/json, text/plain, */*',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (originalHeaders && typeof originalHeaders === 'object') {
|
|
43
|
+
if (originalHeaders['content-type']) {
|
|
44
|
+
headers['Content-Type'] = originalHeaders['content-type']
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const token = String(config?.remoteServerToken || '').trim()
|
|
49
|
+
if (token) {
|
|
50
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
51
|
+
headers['Cookie'] = `dsh_token=${token}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return headers
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Relay an HTTP request to remote DSH server.
|
|
59
|
+
*
|
|
60
|
+
* @param {{
|
|
61
|
+
* config: object,
|
|
62
|
+
* path: string,
|
|
63
|
+
* method?: string,
|
|
64
|
+
* body?: any,
|
|
65
|
+
* headers?: object,
|
|
66
|
+
* timeoutMs?: number
|
|
67
|
+
* }} options
|
|
68
|
+
* @returns {Promise<{ status: number, body: any, headers?: object }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function relayRequest({ config, path, urlPath, method = 'GET', body, headers, timeoutMs = 15000 }) {
|
|
71
|
+
const targetPath = urlPath || path || ''
|
|
72
|
+
const url = getRemoteServerUrl(config, targetPath)
|
|
73
|
+
const reqHeaders = buildRemoteHeaders(config, headers)
|
|
74
|
+
|
|
75
|
+
const fetchOptions = {
|
|
76
|
+
method,
|
|
77
|
+
headers: reqHeaders,
|
|
78
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') {
|
|
82
|
+
if (typeof body === 'string') {
|
|
83
|
+
fetchOptions.body = body
|
|
84
|
+
if (!fetchOptions.headers['Content-Type']) {
|
|
85
|
+
fetchOptions.headers['Content-Type'] = 'application/json'
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
fetchOptions.body = JSON.stringify(body)
|
|
89
|
+
fetchOptions.headers['Content-Type'] = 'application/json'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(url, fetchOptions)
|
|
95
|
+
const contentType = res.headers.get('content-type') || ''
|
|
96
|
+
let parsedBody
|
|
97
|
+
if (contentType.includes('application/json')) {
|
|
98
|
+
parsedBody = await res.json()
|
|
99
|
+
} else {
|
|
100
|
+
parsedBody = await res.text()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
status: res.status,
|
|
105
|
+
body: parsedBody,
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return {
|
|
109
|
+
status: 503,
|
|
110
|
+
body: {
|
|
111
|
+
error: 'remote-server-unavailable',
|
|
112
|
+
detail: `Failed to reach remote DSH server at ${url}: ${err.message}`,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Probe remote DSH server connection and kanban board endpoint.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} config
|
|
122
|
+
* @returns {Promise<{ ok: boolean, latencyMs?: number, status?: number, error?: string }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function testRemoteServer(config) {
|
|
125
|
+
const startTime = Date.now()
|
|
126
|
+
const res = await relayRequest({
|
|
127
|
+
config,
|
|
128
|
+
urlPath: '/dsh-kanban/board',
|
|
129
|
+
method: 'GET',
|
|
130
|
+
timeoutMs: 8000,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const latencyMs = Date.now() - startTime
|
|
134
|
+
|
|
135
|
+
if (res.status === 200) {
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
latencyMs,
|
|
139
|
+
status: res.status,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
latencyMs,
|
|
146
|
+
status: res.status,
|
|
147
|
+
error: res.body?.detail || res.body?.error || `HTTP ${res.status}`,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote SSH runner for git and worktree operations (#390).
|
|
3
|
+
* Allows DSH running on PC (Windows) to execute commands on remote server (111).
|
|
4
|
+
* Zero Cyrillic literals in lib/ according to Issue #366 standard.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check whether remote execution mode is enabled in config.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} config
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function isRemoteMode(config) {
|
|
19
|
+
return Boolean(config && config.remoteMode)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Quote argument safely for remote POSIX shell execution.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|number} s
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function quoteArg(s) {
|
|
29
|
+
return "'" + String(s ?? '').replace(/'/g, "'\\''") + "'"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build arguments array for SSH CLI process.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} config
|
|
36
|
+
* @param {string} remoteCommand
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function buildSshArgs(config, remoteCommand) {
|
|
40
|
+
const host = String(config?.remoteHost || '192.168.1.111').trim()
|
|
41
|
+
const port = Number(config?.remotePort) || 22
|
|
42
|
+
const user = String(config?.remoteUser || 'migrate').trim()
|
|
43
|
+
const keyPath = String(config?.remoteKeyPath || '').trim()
|
|
44
|
+
const sudoUser = config?.remoteSudoUser !== undefined ? String(config.remoteSudoUser).trim() : 'vadim'
|
|
45
|
+
|
|
46
|
+
const args = [
|
|
47
|
+
'-p', String(port),
|
|
48
|
+
'-o', 'BatchMode=yes',
|
|
49
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
50
|
+
'-o', 'ConnectTimeout=10',
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
if (keyPath) {
|
|
54
|
+
args.push('-i', keyPath)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const target = `${user}@${host}`
|
|
58
|
+
args.push(target)
|
|
59
|
+
|
|
60
|
+
let fullCmd = remoteCommand
|
|
61
|
+
if (sudoUser) {
|
|
62
|
+
fullCmd = `sudo -u ${sudoUser} -H bash -c ${quoteArg(remoteCommand)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
args.push(fullCmd)
|
|
66
|
+
return args
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a git runner function that delegates to remote host over SSH when remoteMode is active.
|
|
71
|
+
* Conforms to (args, cwd) => Promise<{ stdout: string, stderr: string }>.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} config
|
|
74
|
+
* @param {Function} [localGitRunner] fallback local runner
|
|
75
|
+
* @returns {Function}
|
|
76
|
+
*/
|
|
77
|
+
export function createRemoteGitRunner(config, localGitRunner) {
|
|
78
|
+
return async function remoteGitRunner(args, cwd) {
|
|
79
|
+
if (!isRemoteMode(config)) {
|
|
80
|
+
if (typeof localGitRunner === 'function') {
|
|
81
|
+
return localGitRunner(args, cwd)
|
|
82
|
+
}
|
|
83
|
+
throw new Error('[dsh-kanban] No local git runner available in non-remote mode')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const escapedArgs = (Array.isArray(args) ? args : []).map(quoteArg).join(' ')
|
|
87
|
+
const cdPart = cwd ? `cd ${quoteArg(cwd)} && ` : ''
|
|
88
|
+
const gitCmd = `${cdPart}git ${escapedArgs}`
|
|
89
|
+
const sshArgs = buildSshArgs(config, gitCmd)
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const { stdout, stderr } = await execFileAsync('ssh', sshArgs, {
|
|
93
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
94
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
|
|
95
|
+
})
|
|
96
|
+
return { stdout: stdout ?? '', stderr: stderr ?? '' }
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const error = new Error(`[dsh-kanban-remote] git ${args?.[0] || ''} failed on ${config?.remoteHost || 'remote'}: ${err.message}`)
|
|
99
|
+
error.code = err.code ?? 1
|
|
100
|
+
error.stdout = err.stdout ?? ''
|
|
101
|
+
error.stderr = err.stderr ?? err.message ?? ''
|
|
102
|
+
throw error
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check connectivity to remote server and verify remote root directory.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} config
|
|
111
|
+
* @returns {Promise<{ ok: boolean, latencyMs?: number, remoteRootFound?: boolean, error?: string }>}
|
|
112
|
+
*/
|
|
113
|
+
export async function testRemoteConnection(config) {
|
|
114
|
+
const root = String(config?.remoteRoot || '/mnt/external/Project/DEV').trim()
|
|
115
|
+
const probeCmd = `test -d ${quoteArg(root)} && echo REMOTE_ROOT_OK || echo REMOTE_ROOT_MISSING`
|
|
116
|
+
const sshArgs = buildSshArgs(config, probeCmd)
|
|
117
|
+
|
|
118
|
+
const startTime = Date.now()
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await execFileAsync('ssh', sshArgs, {
|
|
121
|
+
maxBuffer: 1024 * 1024,
|
|
122
|
+
timeout: 12000,
|
|
123
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
124
|
+
})
|
|
125
|
+
const latencyMs = Date.now() - startTime
|
|
126
|
+
const trimmed = (stdout || '').trim()
|
|
127
|
+
const remoteRootFound = trimmed.includes('REMOTE_ROOT_OK')
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
latencyMs,
|
|
132
|
+
remoteRootFound,
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: err.stderr ? err.stderr.trim() : err.message,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if path exists on remote host.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} remotePath
|
|
146
|
+
* @param {object} config
|
|
147
|
+
* @returns {Promise<boolean>}
|
|
148
|
+
*/
|
|
149
|
+
export async function remotePathExists(remotePath, config) {
|
|
150
|
+
if (!remotePath) return false
|
|
151
|
+
const probeCmd = `test -e ${quoteArg(remotePath)} && echo 1 || echo 0`
|
|
152
|
+
const sshArgs = buildSshArgs(config, probeCmd)
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const { stdout } = await execFileAsync('ssh', sshArgs, {
|
|
156
|
+
maxBuffer: 1024 * 1024,
|
|
157
|
+
timeout: 10000,
|
|
158
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
159
|
+
})
|
|
160
|
+
return (stdout || '').trim() === '1'
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from '../config.js'
|
|
2
|
+
import { parsePlan, planItems, planProgress, taskState, queuePosition } from '../plan.js'
|
|
3
|
+
import { columnStats, doneWithin, stale } from '../metrics.js'
|
|
4
|
+
import { isStale } from '../time.js'
|
|
5
|
+
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from '../filters.js'
|
|
6
|
+
import { computeNextRun } from '../cron.js'
|
|
7
|
+
|
|
8
|
+
export function withPlan(task, liveOf, config, now, sameSession) {
|
|
9
|
+
const plan = parsePlan(task.plan)
|
|
10
|
+
// Registry error implies agent unavailable; do not fail whole board render.
|
|
11
|
+
let live
|
|
12
|
+
try {
|
|
13
|
+
if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
|
|
14
|
+
} catch { live = undefined }
|
|
15
|
+
const liveStatus = typeof live === 'object' && live !== null ? live.status : live
|
|
16
|
+
const liveStep = typeof live === 'object' && live !== null
|
|
17
|
+
? (live.step || live.activity || live.stateDetail || '')
|
|
18
|
+
: ''
|
|
19
|
+
|
|
20
|
+
const costMetrics = task.costMetrics || null
|
|
21
|
+
const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
|
|
22
|
+
? costMetrics.costUsd >= Number(config.taskCostLimit)
|
|
23
|
+
: Boolean(task.costLimitExceeded)
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...task,
|
|
27
|
+
plan: { items: planItems(plan), progress: planProgress(plan) },
|
|
28
|
+
state: taskState({ task, live: liveStatus }),
|
|
29
|
+
liveStep: liveStep || task.liveStep || '',
|
|
30
|
+
costMetrics,
|
|
31
|
+
costLimitExceeded,
|
|
32
|
+
// Queue position calculated dynamically across active tasks in same session.
|
|
33
|
+
queuePos: queuePosition(task, sameSession),
|
|
34
|
+
// Label facets extracted on server for consistent client filtering.
|
|
35
|
+
facets: facetsOfTask(task),
|
|
36
|
+
// Stale calculation performed on host where configuration resides.
|
|
37
|
+
stale: isStale({
|
|
38
|
+
now,
|
|
39
|
+
updatedAt: task.updatedAt,
|
|
40
|
+
sessionId: task.sessionId,
|
|
41
|
+
afterMinutes: config?.staleAfterMin,
|
|
42
|
+
}),
|
|
43
|
+
// Overdue calculated on host using server clock.
|
|
44
|
+
overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Order tasks: by column, grouped by repository, with active tasks on top. */
|
|
49
|
+
function orderForBoard(tasks, columns) {
|
|
50
|
+
const out = []
|
|
51
|
+
for (const column of columns) {
|
|
52
|
+
const inColumn = tasks.filter((t) => t.column === column)
|
|
53
|
+
for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
|
|
54
|
+
}
|
|
55
|
+
// Retain tasks in columns not visible on current board to prevent data loss.
|
|
56
|
+
const seen = new Set(out.map((t) => t.id))
|
|
57
|
+
for (const task of tasks) if (!seen.has(task.id)) out.push(task)
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build board response payload: columns in canonical order, tasks, and limits.
|
|
63
|
+
*/
|
|
64
|
+
export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
|
|
65
|
+
// Uniform timestamp across snapshot calculation.
|
|
66
|
+
const now = Date.now()
|
|
67
|
+
// Tasks are sorted and grouped on server: repo grouping with active items first.
|
|
68
|
+
const raw = store.listTasks({ board, repo })
|
|
69
|
+
// Queue position scoped per session.
|
|
70
|
+
const bySession = new Map()
|
|
71
|
+
for (const task of raw) {
|
|
72
|
+
const id = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
73
|
+
if (id === '') continue
|
|
74
|
+
if (!bySession.has(id)) bySession.set(id, [])
|
|
75
|
+
bySession.get(id).push(task)
|
|
76
|
+
}
|
|
77
|
+
const tasks = orderForBoard(
|
|
78
|
+
raw.map((task) => {
|
|
79
|
+
const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
|
|
80
|
+
if (task.isEpic) {
|
|
81
|
+
enriched.subtasksCount = store.countSubtasks(task.id)
|
|
82
|
+
}
|
|
83
|
+
return enriched
|
|
84
|
+
}),
|
|
85
|
+
columnsOf(store.boardKind(board)),
|
|
86
|
+
)
|
|
87
|
+
const kind = store.boardKind(board)
|
|
88
|
+
// Column custom names sent from server; locale translation handled by client.
|
|
89
|
+
const names = columnNamesOf(config)
|
|
90
|
+
const columns = columnsOf(kind).map((id) => {
|
|
91
|
+
const count = tasks.filter((t) => t.column === id).length
|
|
92
|
+
const limit = wipLimitField(config, id)
|
|
93
|
+
return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
|
|
94
|
+
})
|
|
95
|
+
// Filter counts calculated over all tasks on board.
|
|
96
|
+
return {
|
|
97
|
+
board, kind, now, boards: store.listBoards(), columns, tasks,
|
|
98
|
+
// Resolved project root {path, set} computed on host.
|
|
99
|
+
projectRoot: projectRoot ?? { path: '', set: false },
|
|
100
|
+
|
|
101
|
+
facets: facetsOf(tasks),
|
|
102
|
+
// Sync telemetry state attached directly to board snapshot.
|
|
103
|
+
sync: sync ?? { state: 'never' },
|
|
104
|
+
costMeterAvailable: Boolean(costMeterAvailable),
|
|
105
|
+
remoteMode: Boolean(config?.remoteMode),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Card move transition. WIP limit is advisory: move executes, and exceeded status
|
|
111
|
+
* is returned for UI highlighting.
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
|
|
115
|
+
const tasks = store.listTasks({ board })
|
|
116
|
+
const byTask = new Map()
|
|
117
|
+
for (const row of store.listAllTransitions()) {
|
|
118
|
+
if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
|
|
119
|
+
byTask.get(row.taskId).push(row)
|
|
120
|
+
}
|
|
121
|
+
const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
|
|
122
|
+
const day = 24 * 60 * 60 * 1000
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
tasks: tasks.length,
|
|
126
|
+
columns: columnStats(rows, now),
|
|
127
|
+
done: {
|
|
128
|
+
week: doneWithin(rows, now - 7 * day, now),
|
|
129
|
+
month: doneWithin(rows, now - 30 * day, now),
|
|
130
|
+
},
|
|
131
|
+
staleDays,
|
|
132
|
+
stale: stale(tasks, now, staleDays * day).slice(0, 50),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check whether a task can be resumed.
|
|
138
|
+
* Eligible when task has recorded sessionId but no active agent is running.
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
export function exportBoard({ store }) {
|
|
142
|
+
return store.exportAll()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Restore board from backup snapshot (idempotent; skips existing IDs).
|
|
147
|
+
*
|
|
148
|
+
* @returns {{imported: number, skipped: number, error?: string}}
|
|
149
|
+
*/
|
|
150
|
+
export function importBoard({ store, input }) {
|
|
151
|
+
let backupMeta = undefined
|
|
152
|
+
if (typeof store.backupDatabase === 'function') {
|
|
153
|
+
try {
|
|
154
|
+
backupMeta = store.backupDatabase()
|
|
155
|
+
} catch { /* backup best effort */ }
|
|
156
|
+
}
|
|
157
|
+
if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
|
|
158
|
+
if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
|
|
159
|
+
if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
|
|
160
|
+
|
|
161
|
+
let imported = 0
|
|
162
|
+
let skipped = 0
|
|
163
|
+
|
|
164
|
+
const doImport = () => {
|
|
165
|
+
for (const board of Array.isArray(input.boards) ? input.boards : []) {
|
|
166
|
+
if (typeof board?.id !== 'string' || board.id === '') continue
|
|
167
|
+
store.createBoard({
|
|
168
|
+
id: board.id,
|
|
169
|
+
title: board.title,
|
|
170
|
+
kind: board.kind,
|
|
171
|
+
position: board.position,
|
|
172
|
+
createdAt: board.createdAt,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const task of input.tasks) {
|
|
177
|
+
if (typeof task?.id !== 'string' || task.id === '') continue
|
|
178
|
+
if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
|
|
179
|
+
if (typeof store.restoreTask === 'function') {
|
|
180
|
+
store.restoreTask(task)
|
|
181
|
+
} else {
|
|
182
|
+
store.createTask(task)
|
|
183
|
+
}
|
|
184
|
+
imported += 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
|
|
188
|
+
if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
|
|
189
|
+
if (typeof store.restoreTransition === 'function') {
|
|
190
|
+
store.restoreTransition(trans)
|
|
191
|
+
} else if (typeof store.addTransition === 'function') {
|
|
192
|
+
store.addTransition(trans)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (typeof store.withTransaction === 'function') {
|
|
198
|
+
store.withTransaction(doImport)
|
|
199
|
+
} else {
|
|
200
|
+
doImport()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { imported, skipped, backup: backupMeta?.backup }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
|
|
208
|
+
*
|
|
209
|
+
* @param {{store: object, board?: string}} options
|
|
210
|
+
* @returns {string} CSV string with UTF-8 BOM prefix
|
|
211
|
+
*/
|
|
212
|
+
export function exportTasksCsv({ store, board = 'main' }) {
|
|
213
|
+
const tasks = store.listTasks({ board })
|
|
214
|
+
const headers = [
|
|
215
|
+
'ID',
|
|
216
|
+
'Title',
|
|
217
|
+
'Column',
|
|
218
|
+
'Priority',
|
|
219
|
+
'Repository',
|
|
220
|
+
'Issue #',
|
|
221
|
+
'Assignee',
|
|
222
|
+
'Model',
|
|
223
|
+
'Provider',
|
|
224
|
+
'Reasoning Effort',
|
|
225
|
+
'Workspace',
|
|
226
|
+
'Branch',
|
|
227
|
+
'Created',
|
|
228
|
+
'Updated',
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
function escapeCsv(val) {
|
|
232
|
+
if (val === null || val === undefined) return ''
|
|
233
|
+
let str = String(val)
|
|
234
|
+
// OWASP CSV Injection mitigation (#340)
|
|
235
|
+
const trimmed = str.replace(/^[\s\t\r\n]+/, '')
|
|
236
|
+
if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
|
|
237
|
+
str = `'${str}`
|
|
238
|
+
}
|
|
239
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
240
|
+
return `"${str.replace(/"/g, '""')}"`
|
|
241
|
+
}
|
|
242
|
+
return str
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatDate(ts) {
|
|
246
|
+
if (!ts || typeof ts !== 'number') return ''
|
|
247
|
+
try {
|
|
248
|
+
return new Date(ts).toISOString().replace('T', ' ').slice(0, 19)
|
|
249
|
+
} catch {
|
|
250
|
+
return ''
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const rows = [headers.join(',')]
|
|
255
|
+
for (const t of tasks) {
|
|
256
|
+
const row = [
|
|
257
|
+
escapeCsv(t.id),
|
|
258
|
+
escapeCsv(t.title),
|
|
259
|
+
escapeCsv(t.column),
|
|
260
|
+
escapeCsv(t.priority),
|
|
261
|
+
escapeCsv(t.repo),
|
|
262
|
+
escapeCsv(t.issueNumber ?? ''),
|
|
263
|
+
escapeCsv(t.assignee),
|
|
264
|
+
escapeCsv(t.model),
|
|
265
|
+
escapeCsv(t.provider),
|
|
266
|
+
escapeCsv(t.reasoningEffort ?? ''),
|
|
267
|
+
escapeCsv(t.workspaceId),
|
|
268
|
+
escapeCsv(t.branch),
|
|
269
|
+
escapeCsv(formatDate(t.createdAt)),
|
|
270
|
+
escapeCsv(formatDate(t.updatedAt)),
|
|
271
|
+
]
|
|
272
|
+
rows.push(row.join(','))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// UTF-8 BOM prefix for Microsoft Excel
|
|
276
|
+
return '\uFEFF' + rows.join('\r\n')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clean up task worktree.
|
|
282
|
+
*/
|