@goodandready/dsh-kanban 0.2.19 → 0.2.21
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 +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +30 -55
- package/lib/cron.js +13 -13
- package/lib/events.js +17 -5
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +59 -110
- package/lib/github.js +39 -37
- package/lib/import.js +46 -67
- package/lib/index.js +131 -140
- package/lib/intake.js +18 -26
- package/lib/launcher.js +107 -170
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +28 -28
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +92 -145
- package/lib/store.js +68 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +44 -61
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +72 -68
- package/lib/worktree.js +103 -60
- package/package.json +1 -1
package/lib/lifecycle.js
CHANGED
|
@@ -1,34 +1,28 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// или вопрос агента НЕ двигают карточку, а поднимают на ней признак «нужен
|
|
5
|
-
// человек». Двигать в несуществующую колонку нельзя, а прятать «агент стоит и
|
|
6
|
-
// ждёт» — значит терять главное, ради чего смотрят на доску.
|
|
7
|
-
//
|
|
8
|
-
// Подписка на события ядра живёт в `lib/index.js`; здесь только решение, что
|
|
9
|
-
// делать с очередным событием.
|
|
1
|
+
// Session waiting indicators.
|
|
2
|
+
// Permission requests or agent prompts raise a 'waiting for user' flag on card
|
|
3
|
+
// without moving it to a non-existent blocked column.
|
|
10
4
|
|
|
11
5
|
import { waitingFromEvent } from './transitions.js'
|
|
12
6
|
import { parseProgressDump, formatProgressDump } from './progress-dump.js'
|
|
13
7
|
|
|
14
8
|
/**
|
|
15
|
-
*
|
|
9
|
+
* Process session event.
|
|
16
10
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
11
|
+
* Looks up task by sessionId. Non-kanban sessions are cleanly
|
|
12
|
+
* ignored without side effects.
|
|
13
|
+
|
|
20
14
|
*
|
|
21
|
-
* @returns {{taskId: string, waiting?: boolean}|undefined}
|
|
15
|
+
* @returns {{taskId: string, waiting?: boolean}|undefined} mutation report
|
|
22
16
|
*/
|
|
23
17
|
export function handleSessionEvent({ store, sessionId, session, type, text, error }) {
|
|
24
|
-
//
|
|
18
|
+
// Filter out subagent child session events (#215)
|
|
25
19
|
if (session && (session.origin === 'subagent' || session.parentId !== undefined || session.meta?.origin === 'subagent' || session.isSubagent)) {
|
|
26
20
|
return undefined
|
|
27
21
|
}
|
|
28
22
|
const task = store.findTaskBySession(sessionId)
|
|
29
23
|
if (task === undefined) return undefined
|
|
30
24
|
|
|
31
|
-
//
|
|
25
|
+
// If turn returned PROGRESSDUMP, update task snapshot
|
|
32
26
|
if (typeof text === 'string' && text.includes('<<<PROGRESSDUMP')) {
|
|
33
27
|
const parsed = parseProgressDump(text)
|
|
34
28
|
if (parsed.ok) {
|
|
@@ -36,7 +30,7 @@ export function handleSessionEvent({ store, sessionId, session, type, text, erro
|
|
|
36
30
|
}
|
|
37
31
|
}
|
|
38
32
|
|
|
39
|
-
//
|
|
33
|
+
// Update run outcome in run history
|
|
40
34
|
if (type === 'turn/end' || type === 'turn/completed') {
|
|
41
35
|
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
42
36
|
if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
package/lib/markdown.js
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Parse task body into node tree.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// бдительности того, кто вспомнит про экранирование.
|
|
3
|
+
// Body originates from Gitea and can contain arbitrary user content.
|
|
4
|
+
// Parser returns a node TREE rather than raw HTML string: frontend renders
|
|
5
|
+
// via React elements, preventing XSS injection structurally.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// текстом как есть — это честнее, чем угадывать.
|
|
7
|
+
// Full markdown engine is intentionally omitted: headings, lists, formatting,
|
|
8
|
+
// code blocks and links cover typical issue bodies. Unrecognized text remains as-is.
|
|
11
9
|
|
|
12
|
-
/**
|
|
10
|
+
/** Allowed link protocols: `javascript:` in body is blocked. */
|
|
13
11
|
const SAFE_LINK = /^https?:\/\//i
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
14
|
+
* Parse body into list of blocks.
|
|
17
15
|
*
|
|
18
|
-
* @returns {Array<object>}
|
|
16
|
+
* @returns {Array<object>} blocks shaped as
|
|
19
17
|
* `{kind:'heading', level, spans}`, `{kind:'para', spans}`,
|
|
20
18
|
* `{kind:'list', ordered, items:[spans]}`, `{kind:'code', text}`,
|
|
21
19
|
* `{kind:'quote', spans}`, `{kind:'rule'}`
|
|
@@ -30,7 +28,7 @@ export function parseBody(text) {
|
|
|
30
28
|
|
|
31
29
|
if (line.trim() === '') { i += 1; continue }
|
|
32
30
|
|
|
33
|
-
//
|
|
31
|
+
// Code block: content preserved verbatim, including markup-like sequences.
|
|
34
32
|
const fence = /^\s*```(.*)$/.exec(line)
|
|
35
33
|
if (fence !== null) {
|
|
36
34
|
const body = []
|
|
@@ -77,7 +75,7 @@ export function parseBody(text) {
|
|
|
77
75
|
continue
|
|
78
76
|
}
|
|
79
77
|
|
|
80
|
-
//
|
|
78
|
+
// Paragraph: lines up to blank line are merged per markdown semantics.
|
|
81
79
|
const body = []
|
|
82
80
|
while (i < lines.length && lines[i].trim() !== ''
|
|
83
81
|
&& !/^\s*(#{1,6})\s+/.test(lines[i])
|
|
@@ -95,14 +93,14 @@ export function parseBody(text) {
|
|
|
95
93
|
}
|
|
96
94
|
|
|
97
95
|
/**
|
|
98
|
-
*
|
|
96
|
+
* Parse line into inline segments: plain text, code, emphasis, links.
|
|
99
97
|
*
|
|
100
98
|
* @returns {Array<{kind: 'text'|'code'|'strong'|'em'|'link', text: string, href?: string}>}
|
|
101
99
|
*/
|
|
102
100
|
export function parseSpans(text) {
|
|
103
101
|
const out = []
|
|
104
102
|
const src = String(text ?? '')
|
|
105
|
-
//
|
|
103
|
+
// Code spans first: inside backticks markup is treated literally.
|
|
106
104
|
const re = /(`[^`]+`)|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
|
|
107
105
|
let at = 0
|
|
108
106
|
let m = re.exec(src)
|
|
@@ -117,8 +115,7 @@ export function parseSpans(text) {
|
|
|
117
115
|
const cut = piece.indexOf('](')
|
|
118
116
|
const label = piece.slice(1, cut)
|
|
119
117
|
const href = piece.slice(cut + 2, -1)
|
|
120
|
-
//
|
|
121
|
-
// человек должен видеть, что там было написано.
|
|
118
|
+
// Unsafe protocol rendered as plain text rather than active link.
|
|
122
119
|
if (isSafeHref(href)) out.push({ kind: 'link', text: label, href })
|
|
123
120
|
else out.push({ kind: 'text', text: piece })
|
|
124
121
|
} else if (piece.startsWith('**') || piece.startsWith('__')) {
|
|
@@ -136,11 +133,7 @@ export function parseSpans(text) {
|
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* Разбор дробит строку там, где нашёл разметку и передумал: `[клик](javascript:…)`
|
|
142
|
-
* не стал ссылкой, но оставил после себя два куска. Дробление наружу не
|
|
143
|
-
* выпускаем — оно не значит ничего, а читателю мешает.
|
|
136
|
+
* Merge adjacent text segments to avoid fragmentation.
|
|
144
137
|
*/
|
|
145
138
|
function mergeText(spans) {
|
|
146
139
|
const out = []
|
|
@@ -155,7 +148,7 @@ function mergeText(spans) {
|
|
|
155
148
|
return out
|
|
156
149
|
}
|
|
157
150
|
|
|
158
|
-
/**
|
|
151
|
+
/** Whether a link protocol is safe. Exported so frontend does not recompute. */
|
|
159
152
|
export function isSafeHref(href) {
|
|
160
153
|
return SAFE_LINK.test(String(href ?? ''))
|
|
161
154
|
}
|
package/lib/metrics.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Board metrics: view over transition history, not new mutable state.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// History accumulates from day one, but was previously queried per-task.
|
|
4
|
+
// Questions like 'where is bottleneck', 'task lifetime', 'stale tasks'
|
|
5
|
+
// can all be answered from transition records.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Read-only calculation: metrics do not write state to avoid becoming an
|
|
8
|
+
// unaligned second source of truth.
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Time spent by task in each column.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Transition closes previous time slice and opens next.
|
|
14
|
+
* Final slice closes at present moment.
|
|
15
15
|
*
|
|
16
|
-
* @param {Array<{toCol: string, at: number}>} moves
|
|
17
|
-
* @param {number} bornAt
|
|
16
|
+
* @param {Array<{toCol: string, at: number}>} moves task transitions, oldest first
|
|
17
|
+
* @param {number} bornAt task creation timestamp
|
|
18
18
|
* @param {number} now
|
|
19
|
-
* @returns {Object<string, number>}
|
|
19
|
+
* @returns {Object<string, number>} column -> milliseconds
|
|
20
20
|
*/
|
|
21
21
|
export function timeInColumns(moves, bornAt, now) {
|
|
22
22
|
const rows = (moves ?? [])
|
|
@@ -25,8 +25,8 @@ export function timeInColumns(moves, bornAt, now) {
|
|
|
25
25
|
.sort((a, b) => a.at - b.at)
|
|
26
26
|
|
|
27
27
|
const out = {}
|
|
28
|
-
//
|
|
29
|
-
//
|
|
28
|
+
// Prior to first transition, task resides in initial column.
|
|
29
|
+
// Derived from `fromCol` of first transition.
|
|
30
30
|
let col = rows.length > 0 && typeof rows[0].fromCol === 'string' ? rows[0].fromCol : 'backlog'
|
|
31
31
|
let since = typeof bornAt === 'number' ? bornAt : (rows[0]?.at ?? now)
|
|
32
32
|
|
|
@@ -44,7 +44,7 @@ export function timeInColumns(moves, bornAt, now) {
|
|
|
44
44
|
return out
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/** Median calculation. */
|
|
48
48
|
export function median(values) {
|
|
49
49
|
const rows = (values ?? []).filter((x) => typeof x === 'number').slice().sort((a, b) => a - b)
|
|
50
50
|
if (rows.length === 0) return 0
|
|
@@ -52,7 +52,7 @@ export function median(values) {
|
|
|
52
52
|
return rows.length % 2 === 1 ? rows[mid] : Math.round((rows[mid - 1] + rows[mid]) / 2)
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/** Mean average; empty set returns zero without dividing by zero. */
|
|
56
56
|
export function mean(values) {
|
|
57
57
|
const rows = (values ?? []).filter((x) => typeof x === 'number')
|
|
58
58
|
if (rows.length === 0) return 0
|
|
@@ -60,10 +60,10 @@ export function mean(values) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* Column summary: average and median time spent in each column.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
65
|
+
* Median is preferred over average: a single forgotten task can skew
|
|
66
|
+
* the average heavily.
|
|
67
67
|
*
|
|
68
68
|
* @param {Array<{task: object, moves: object[]}>} rows
|
|
69
69
|
* @returns {Array<{column: string, mean: number, median: number, tasks: number}>}
|
|
@@ -88,10 +88,9 @@ export function columnStats(rows, now) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
91
|
+
* Tasks that stayed in their current column longer than threshold.
|
|
92
92
|
*
|
|
93
|
-
*
|
|
94
|
-
* при этом двигаться каждый день.
|
|
93
|
+
* Measured from column entry timestamp rather than creation date.
|
|
95
94
|
*
|
|
96
95
|
* @returns {Array<{id: string, title: string, column: string, since: number}>}
|
|
97
96
|
*/
|
|
@@ -112,10 +111,9 @@ export function stale(tasks, now, thresholdMs) {
|
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
/**
|
|
115
|
-
*
|
|
114
|
+
* How many tasks reached 'done' within given time period.
|
|
116
115
|
*
|
|
117
|
-
*
|
|
118
|
-
* всё равно была сделана.
|
|
116
|
+
* Computed from transitions rather than current column to account for archived tasks.
|
|
119
117
|
*/
|
|
120
118
|
export function doneWithin(rows, from, to) {
|
|
121
119
|
let count = 0
|
package/lib/order.js
CHANGED
|
@@ -1,35 +1,33 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Fractional ordering index for cards.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// было в колонке.
|
|
3
|
+
// Naive approach stores integer index, and dragging card to the top of column
|
|
4
|
+
// with ten items requires rewriting all ten. Here order is stored as string,
|
|
5
|
+
// and new value is generated BETWEEN two neighbors, so drag-and-drop is always
|
|
6
|
+
// a single UPDATE of one row regardless of how many cards are in column.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// вставке в одно и то же место, когда кончается разрядность.
|
|
8
|
+
// String-based implementation is intentional. Floating-point numbers fail
|
|
9
|
+
// after ~50 insertions into the same position when precision runs out.
|
|
12
10
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
11
|
+
// Key is read as fraction after implicit '0.': comparison is standard
|
|
12
|
+
// lexicographical. Key never ends with first alphabet character to ensure
|
|
13
|
+
// subsequent division remains possible.
|
|
16
14
|
|
|
17
|
-
/**
|
|
15
|
+
/** Alphabet in ascending character code order. */
|
|
18
16
|
const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
19
17
|
const FIRST = DIGITS[0]
|
|
20
18
|
|
|
21
|
-
/**
|
|
19
|
+
/** Value strictly between `a` and `b`; `b === undefined` means unbounded right. */
|
|
22
20
|
function midpoint(a, b) {
|
|
23
21
|
if (b !== undefined && a >= b) {
|
|
24
|
-
throw new Error(
|
|
22
|
+
throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
|
|
25
23
|
}
|
|
26
24
|
if (a.endsWith(FIRST) || (b !== undefined && b.endsWith(FIRST))) {
|
|
27
|
-
throw new Error('
|
|
25
|
+
throw new Error('order key cannot end with first alphabet symbol')
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
if (b !== undefined) {
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// Common prefix carried as-is, division continues on remainder
|
|
30
|
+
// to avoid inflating key length unnecessarily.
|
|
33
31
|
let n = 0
|
|
34
32
|
while ((a[n] ?? FIRST) === b[n]) n += 1
|
|
35
33
|
if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
|
|
@@ -37,7 +35,7 @@ function midpoint(a, b) {
|
|
|
37
35
|
|
|
38
36
|
const from = a === '' ? 0 : DIGITS.indexOf(a[0])
|
|
39
37
|
const to = b === undefined ? DIGITS.length : DIGITS.indexOf(b[0])
|
|
40
|
-
if (from < 0 || to < 0) throw new Error('
|
|
38
|
+
if (from < 0 || to < 0) throw new Error('order key contains character outside alphabet')
|
|
41
39
|
|
|
42
40
|
if (to - from > 1) {
|
|
43
41
|
return DIGITS[Math.round(0.5 * (from + to))]
|
|
@@ -45,21 +43,20 @@ function midpoint(a, b) {
|
|
|
45
43
|
if (b !== undefined && b.length > 1) {
|
|
46
44
|
return b.slice(0, 1)
|
|
47
45
|
}
|
|
48
|
-
//
|
|
46
|
+
// Adjacent symbols: take `a` digit and divide next position.
|
|
49
47
|
return DIGITS[from] + midpoint(a.slice(1), undefined)
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
/**
|
|
53
|
-
*
|
|
51
|
+
* Fractional order key strictly between two neighbors.
|
|
54
52
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* можно снова разделить.
|
|
53
|
+
* Both neighbors are optional: without either returns default initial value;
|
|
54
|
+
* without right returns value after `a`; without left returns value before `b`.
|
|
58
55
|
*
|
|
59
|
-
* @param {string} [a]
|
|
60
|
-
* @param {string} [b]
|
|
61
|
-
* @returns {string}
|
|
62
|
-
* @throws
|
|
56
|
+
* @param {string} [a] left neighbor order key
|
|
57
|
+
* @param {string} [b] right neighbor order key
|
|
58
|
+
* @returns {string} key strictly between them
|
|
59
|
+
* @throws if `a` is not less than `b` or key contains invalid character
|
|
63
60
|
*/
|
|
64
61
|
export function keyBetween(a, b) {
|
|
65
62
|
return midpoint(a ?? '', b ?? undefined)
|
package/lib/outbound.js
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Kanban -> Gitea: closing issue.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// Gitea
|
|
5
|
-
//
|
|
6
|
-
// чужого времени ответа.
|
|
3
|
+
// Sending runs via queue rather than synchronously in move handler.
|
|
4
|
+
// Gitea may be unreachable, but the board must still move cards.
|
|
5
|
+
// A network-blocking move would make the board hostage to external latency.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// Failed dispatch is not dropped silently: it retries, and after
|
|
8
|
+
// several attempts is logged as a problem in task history.
|
|
9
|
+
// Silently swallowed divergence is the worst sync failure mode.
|
|
11
10
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// The board does NOT set column labels. It never reads them:
|
|
12
|
+
// column is derived from issue/branch/PR state, while labels only duplicate it.
|
|
13
|
+
// In exchange, the board leaves external labels untouched.
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
|
-
*
|
|
16
|
+
* What to send to Gitea on assignee change.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
18
|
+
* Does not apply to local tasks (without issue): no remote assignee exists.
|
|
20
19
|
*/
|
|
21
20
|
export function planAssign(task, login) {
|
|
22
21
|
if (typeof task?.issueNumber !== 'number') return []
|
|
@@ -31,20 +30,20 @@ export function planAssign(task, login) {
|
|
|
31
30
|
|
|
32
31
|
export function planOutbound(task, column, config) {
|
|
33
32
|
if (typeof task?.issueNumber !== 'number') return []
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// Closing issue is the only mutation sent to Gitea: it is a concrete action,
|
|
34
|
+
// not redundant mirroring.
|
|
36
35
|
return column === 'done'
|
|
37
36
|
? [{ kind: 'close', owner: task.owner, repo: task.repo, index: task.issueNumber }]
|
|
38
37
|
: []
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/**
|
|
42
|
-
*
|
|
41
|
+
* Dispatch queue with retries.
|
|
43
42
|
*
|
|
44
43
|
* @param {object} options
|
|
45
|
-
* @param {object} options.gitea
|
|
46
|
-
* @param {object} options.store
|
|
47
|
-
* @param {number} [options.maxAttempts]
|
|
44
|
+
* @param {object} options.gitea client
|
|
45
|
+
* @param {object} options.store store where failures are reported
|
|
46
|
+
* @param {number} [options.maxAttempts] max failed attempts before giving up
|
|
48
47
|
*/
|
|
49
48
|
export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
50
49
|
const queue = []
|
|
@@ -57,11 +56,11 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
|
57
56
|
},
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
|
-
*
|
|
59
|
+
* Try delivering all queued operations.
|
|
61
60
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* Success clears operation, failure reenqueues until attempt limit.
|
|
62
|
+
* Once attempts are exhausted, operation is NOT dropped silently:
|
|
63
|
+
* recorded in task history so divergence is visible to user.
|
|
65
64
|
*/
|
|
66
65
|
async flush() {
|
|
67
66
|
const pending = queue.splice(0)
|
|
@@ -89,19 +88,20 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
|
89
88
|
continue
|
|
90
89
|
}
|
|
91
90
|
dropped += 1
|
|
92
|
-
logger?.warn?.(`dsh-kanban:
|
|
91
|
+
logger?.warn?.(`dsh-kanban: Gitea delivery failed permanently: ${error?.message}`)
|
|
93
92
|
try {
|
|
94
|
-
const
|
|
93
|
+
const currentStore = typeof store === 'function' ? store() : store
|
|
94
|
+
const task = currentStore?.getTask?.(item.taskId)
|
|
95
95
|
if (task !== undefined) {
|
|
96
|
-
|
|
96
|
+
currentStore?.addTransition?.({
|
|
97
97
|
taskId: item.taskId,
|
|
98
98
|
fromCol: task.column,
|
|
99
99
|
toCol: task.column,
|
|
100
100
|
source: 'gitea',
|
|
101
|
-
detail:
|
|
101
|
+
detail: `failed delivery to Gitea after ${item.attempts} attempts: ${item.op.kind}`,
|
|
102
102
|
})
|
|
103
103
|
}
|
|
104
|
-
} catch { /*
|
|
104
|
+
} catch { /* log must not fail delivery */ }
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
return { sent, retried, dropped }
|
package/lib/plan.js
CHANGED
|
@@ -1,31 +1,28 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Agent execution plan and task state.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// опираться на его события значит завести зависимость.
|
|
3
|
+
// Plan is captured DIRECTLY via native tool rather than parsing chat messages:
|
|
4
|
+
// message parsing is heuristic, and external todo tools introduce fragile dependencies.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
// выносим: он был бы догадкой там, где есть точный признак.
|
|
6
|
+
// State is an explicit fact rather than timeout inference.
|
|
9
7
|
|
|
10
|
-
/**
|
|
8
|
+
/** Maximum plan items retained: plan is not a log history. */
|
|
11
9
|
export const MAX_ITEMS = 50
|
|
12
10
|
|
|
13
|
-
/**
|
|
11
|
+
/** Truncate items longer than this: displayed in card single-line view. */
|
|
14
12
|
export const MAX_ITEM_LENGTH = 120
|
|
15
13
|
|
|
16
14
|
const EMPTY = { items: [], current: 0 }
|
|
17
15
|
|
|
18
|
-
/**
|
|
16
|
+
/** Coerce item to string of reasonable length. */
|
|
19
17
|
function cleanItem(value) {
|
|
20
18
|
const text = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
21
19
|
return text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH - 1) + '…' : text
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
/**
|
|
25
|
-
*
|
|
23
|
+
* Parse plan from storage.
|
|
26
24
|
*
|
|
27
|
-
*
|
|
28
|
-
* разбора превращается в пустой план.
|
|
25
|
+
* Corrupted field must not break board loading; errors return empty plan.
|
|
29
26
|
*/
|
|
30
27
|
export function parsePlan(raw) {
|
|
31
28
|
if (typeof raw !== 'string' || raw === '') return EMPTY
|
|
@@ -45,11 +42,8 @@ export function serializePlan(plan) {
|
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
/**
|
|
48
|
-
*
|
|
45
|
+
* Pointer to active plan item.
|
|
49
46
|
*
|
|
50
|
-
* Ноль означает «плана нет либо он ещё не начат»; значение больше длины плана —
|
|
51
|
-
* «всё сделано», и удерживать его на последнем пункте нельзя: тогда законченный
|
|
52
|
-
* план вечно показывал бы последний пункт как текущий.
|
|
53
47
|
*/
|
|
54
48
|
function clampCurrent(value, total) {
|
|
55
49
|
const n = Number(value)
|
|
@@ -58,11 +52,8 @@ function clampCurrent(value, total) {
|
|
|
58
52
|
}
|
|
59
53
|
|
|
60
54
|
/**
|
|
61
|
-
*
|
|
55
|
+
* Apply tool update to existing plan.
|
|
62
56
|
*
|
|
63
|
-
* Пункты необязательны: агент публикует план один раз, а дальше двигает только
|
|
64
|
-
* указатель. Требовать список на каждом шаге значило бы гонять его целиком ради
|
|
65
|
-
* одного числа.
|
|
66
57
|
*/
|
|
67
58
|
export function applyPlan(previous, patch) {
|
|
68
59
|
const prev = previous ?? EMPTY
|
|
@@ -76,10 +67,8 @@ export function applyPlan(previous, patch) {
|
|
|
76
67
|
}
|
|
77
68
|
|
|
78
69
|
/**
|
|
79
|
-
*
|
|
70
|
+
* Plan display metadata.
|
|
80
71
|
*
|
|
81
|
-
* Числа и текст пункта отдаём отдельно: строку «3 из 7» собирает браузер, он
|
|
82
|
-
* же её и переводит.
|
|
83
72
|
*
|
|
84
73
|
* @returns {{total: number, done: number, current: number, text: string}|undefined}
|
|
85
74
|
*/
|
|
@@ -87,14 +76,14 @@ export function planProgress(plan) {
|
|
|
87
76
|
const items = plan?.items ?? []
|
|
88
77
|
if (items.length === 0) return undefined
|
|
89
78
|
const current = clampCurrent(plan?.current, items.length)
|
|
90
|
-
//
|
|
91
|
-
//
|
|
79
|
+
// Items before active pointer are considered completed.
|
|
80
|
+
// Items before active pointer are considered completed.
|
|
92
81
|
const done = current === 0 ? 0 : Math.min(current - 1, items.length)
|
|
93
82
|
const text = current >= 1 && current <= items.length ? items[current - 1] : ''
|
|
94
83
|
return { total: items.length, done, current, text }
|
|
95
84
|
}
|
|
96
85
|
|
|
97
|
-
/**
|
|
86
|
+
/** Plan item checklist state. */
|
|
98
87
|
export function planItems(plan) {
|
|
99
88
|
const items = plan?.items ?? []
|
|
100
89
|
const current = clampCurrent(plan?.current, items.length)
|
|
@@ -106,43 +95,32 @@ export function planItems(plan) {
|
|
|
106
95
|
}
|
|
107
96
|
|
|
108
97
|
/**
|
|
109
|
-
*
|
|
98
|
+
* Task state resolution.
|
|
110
99
|
*
|
|
111
|
-
* `stopped` — не догадка по тишине, а факт: у задачи записана сессия, но живого
|
|
112
|
-
* агента за ней нет. Так выглядит прерванная работа после перезапуска харнесса.
|
|
113
100
|
*
|
|
114
|
-
* `queued` — задача отдана сессии, но агент до неё не дошёл: она ждёт своей
|
|
115
|
-
* очереди. Выводится из отметки постановки, а не хранится отдельным полем.
|
|
116
101
|
*
|
|
117
102
|
* @param {{task: object, live: 'running'|'idle'|undefined}} args
|
|
118
|
-
* `live` — состояние живого агента; `undefined` означает, что агента нет.
|
|
119
103
|
*/
|
|
120
104
|
export function taskState({ task, live }) {
|
|
121
105
|
const sessionId = typeof task?.sessionId === 'string' ? task.sessionId : ''
|
|
122
106
|
if (sessionId === '') return 'none'
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
107
|
+
// Task queued for agent session.
|
|
108
|
+
// Task queued for agent session.
|
|
109
|
+
// Task queued for agent session.
|
|
126
110
|
if (typeof task?.queuedAt === 'number' && task.queuedAt > 0) return 'queued'
|
|
127
|
-
//
|
|
128
|
-
//
|
|
111
|
+
// Human waiting takes precedence over running status.
|
|
112
|
+
// Human waiting takes precedence over running status.
|
|
129
113
|
if (task?.waiting === true) return 'waiting'
|
|
130
114
|
if (live === undefined) return 'stopped'
|
|
131
115
|
return live === 'running' ? 'running' : 'idle'
|
|
132
116
|
}
|
|
133
117
|
|
|
134
118
|
/**
|
|
135
|
-
*
|
|
119
|
+
* Task index in session queue.
|
|
136
120
|
*
|
|
137
|
-
* Считается на лету, а не хранится: агент берёт задачи одну за другой, и у
|
|
138
|
-
* оставшихся третий сам становится вторым. Хранимый номер пришлось бы
|
|
139
|
-
* пересчитывать, и однажды его бы забыли.
|
|
140
121
|
*
|
|
141
|
-
* Порядок — по отметке постановки, а НЕ по положению карточки в колонке.
|
|
142
|
-
* Перетаскивание мышью — про то, как задачи лежат; очередь — про то, в каком
|
|
143
|
-
* порядке их получит агент, и менять второе первым нельзя.
|
|
144
122
|
*
|
|
145
|
-
* @returns {number|undefined}
|
|
123
|
+
* @returns {number|undefined} 1-based index in queue
|
|
146
124
|
*/
|
|
147
125
|
export function queuePosition(task, sameSession) {
|
|
148
126
|
const at = task?.queuedAt
|