@goodandready/dsh-kanban 0.1.25 → 0.1.26
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/README.md +8 -0
- package/docs/design/DESIGN.md +1 -0
- package/lib/client.js +123 -1
- package/lib/config.js +12 -0
- package/lib/cron.js +154 -0
- package/lib/index.js +153 -6
- package/lib/launcher.js +43 -1
- package/lib/lifecycle.js +34 -5
- package/lib/power.js +157 -0
- package/lib/progress-dump.js +134 -0
- package/lib/routes.js +22 -1
- package/lib/store.js +61 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -275,6 +275,14 @@ visibility and multi-agent concurrency protection. During the `cleanup` phase, u
|
|
|
275
275
|
are detected via `git status --porcelain` to prevent data loss, followed by clean worktree and workspace
|
|
276
276
|
deregistration.
|
|
277
277
|
|
|
278
|
+
## ⚙️ Host-Side Automation & Governance (v0.1.26+)
|
|
279
|
+
|
|
280
|
+
- **Host-Side Cron Scheduler**: Autonomous 5-field cron parsing (`分 时 日 月 周`) executed in the host Node.js process. Tasks trigger reliably in the background without needing an open browser tab.
|
|
281
|
+
- **Power Inhibitor (`preventIdleSleep`)**: Cross-platform system idle sleep prevention (PowerShell Win32 `SetThreadExecutionState` on Windows, `/usr/bin/systemd-inhibit` on Linux, `/usr/bin/caffeinate` on macOS) active while tasks or sessions run.
|
|
282
|
+
- **PROGRESSDUMP Handover**: Structured progress snapshots (`<<<PROGRESSDUMP ... >>>PROGRESSDUMP`) with secret redaction (`[REDACTED]`) and slash-command suppression, formatting clean task handover preambles for subsequent agents.
|
|
283
|
+
- **Permission Confirmation Gate**: Tasks requesting permissions exceeding the configured baseline (`sessionDefaultPermission`) enter a gated state requiring explicit human confirmation before launch. Modifying parameters resets the gate.
|
|
284
|
+
- **Task Execution History (`runs`)**: Last 20 execution attempts per task stored directly inside SQLite `tasks.runs`, tracking duration, outcome (`succeeded`, `failed`, `cancelled`), model, and direct links to session chats.
|
|
285
|
+
|
|
278
286
|
## 📄 License
|
|
279
287
|
|
|
280
288
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -51,3 +51,4 @@
|
|
|
51
51
|
- 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
|
|
52
52
|
- 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
|
|
53
53
|
- 2026-09-03 — Изоляция сессий задач в git worktree ($DSH_HOME/worktrees/<repo-key>/<task-id>/ на ветке task/<id>-<slug>), регистрация воркдерева в workspaceRegistry, проверка dirtyFiles и безопасная очистка в фазе cleanup (#193).
|
|
54
|
+
- 2026-09-03 — Серверная инфраструктура автономности и безопасности (#195): Host-Side 5-field Cron, PowerInhibitor (кроссплатформенная блокировка сна системы во время работы задач), PROGRESSDUMP (фиксация срезов контекста, маскирование секретов и передача эстафеты), Permission Confirmation Gate (ручное одобрение прав выше базовых), инъекция секции доски в systemPrompt и хранение истории запусков прямо в карточке задачи (tasks.runs).
|
package/lib/client.js
CHANGED
|
@@ -862,6 +862,26 @@ window.__ModuleLoader__.load({
|
|
|
862
862
|
'hint.worktreeIsolation': 'Creates a separate git worktree for each task session to prevent file collisions.',
|
|
863
863
|
'field.worktreeRoot': 'Worktree root directory',
|
|
864
864
|
'hint.worktreeRoot': 'Custom folder for task worktrees. Leave empty to use $DSH_HOME/worktrees.',
|
|
865
|
+
'field.cronEnabled': 'Enable host-side task cron',
|
|
866
|
+
'hint.cronEnabled': 'Runs tasks on scheduled 5-field cron on the host even when browser is closed.',
|
|
867
|
+
'field.preventIdleSleep': 'Prevent system idle sleep',
|
|
868
|
+
'hint.preventIdleSleep': 'Keeps the host awake while tasks are running or armed.',
|
|
869
|
+
'field.progressDumpEnabled': 'Enable PROGRESSDUMP handovers',
|
|
870
|
+
'hint.progressDumpEnabled': 'Captures session progress dumps and provides clean preambles to subsequent agents.',
|
|
871
|
+
'field.permissionGateEnabled': 'Enable permission confirmation gate',
|
|
872
|
+
'hint.permissionGateEnabled': 'Requires manual user confirmation before launching tasks with elevated permissions.',
|
|
873
|
+
'field.sessionDefaultPermission': 'Default permission baseline',
|
|
874
|
+
'hint.sessionDefaultPermission': 'Tasks requesting permissions above this rank require confirmation.',
|
|
875
|
+
'field.announceToAgent': 'Announce board in system prompt',
|
|
876
|
+
'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
|
|
877
|
+
'task.runs': 'Execution History',
|
|
878
|
+
'task.noRuns': 'No runs yet',
|
|
879
|
+
'task.cron': 'Cron schedule',
|
|
880
|
+
'task.nextRun': 'Next run: {date}',
|
|
881
|
+
'task.progressDump': 'Progress Dump (Handover)',
|
|
882
|
+
'task.permissionGateWarning': 'Requires confirmation for elevated permission ({permission})',
|
|
883
|
+
'task.confirmPermission': 'Confirm Permission',
|
|
884
|
+
'task.permissionConfirmed': 'Permission confirmed',
|
|
865
885
|
'hint.pushToGitea': 'A card reaching Done closes its issue. The board sets no labels: the stage is already visible from the branch and the pull request.',
|
|
866
886
|
'hint.webhookSecretRef': 'NAME of a DSH credential holding the Gitea webhook secret. Empty leaves only polling.',
|
|
867
887
|
'hint.syncIntervalSec': 'Zero turns the check off. Below fifteen seconds is raised to fifteen.',
|
|
@@ -1136,6 +1156,26 @@ window.__ModuleLoader__.load({
|
|
|
1136
1156
|
'hint.worktreeIsolation': '为每个任务会话创建独立的 git worktree 以防止文件冲突。',
|
|
1137
1157
|
'field.worktreeRoot': 'Worktree 根目录',
|
|
1138
1158
|
'hint.worktreeRoot': '任务 worktree 的自定义目录。留空则使用 $DSH_HOME/worktrees。',
|
|
1159
|
+
'field.cronEnabled': '启用 Host 端任务定时 (Cron)',
|
|
1160
|
+
'hint.cronEnabled': '在 Host 端按 5 段 cron 自动运行任务,关闭浏览器仍生效。',
|
|
1161
|
+
'field.preventIdleSleep': '防止系统空闲睡眠',
|
|
1162
|
+
'hint.preventIdleSleep': '在任务进行或定时启用期间阻止系统进入空闲睡眠。',
|
|
1163
|
+
'field.progressDumpEnabled': '启用 PROGRESSDUMP 进度交接',
|
|
1164
|
+
'hint.progressDumpEnabled': '提取会话中的进度切片,向接续会话注入清晰的任务前置上下文。',
|
|
1165
|
+
'field.permissionGateEnabled': '启用权限确认门禁',
|
|
1166
|
+
'hint.permissionGateEnabled': '需要人工确认后才允许启动高于默认权限的任务。',
|
|
1167
|
+
'field.sessionDefaultPermission': '基线默认权限',
|
|
1168
|
+
'hint.sessionDefaultPermission': '超出此权限的任务需要点击人工确认。',
|
|
1169
|
+
'field.announceToAgent': '在系统提示词中声明看板',
|
|
1170
|
+
'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
|
|
1171
|
+
'task.runs': '执行历史',
|
|
1172
|
+
'task.noRuns': '暂无执行记录',
|
|
1173
|
+
'task.cron': '定时表达式 (Cron)',
|
|
1174
|
+
'task.nextRun': '下次执行: {date}',
|
|
1175
|
+
'task.progressDump': '进度快照 (交接)',
|
|
1176
|
+
'task.permissionGateWarning': '该任务需要确认较高权限 ({permission})',
|
|
1177
|
+
'task.confirmPermission': '确认权限',
|
|
1178
|
+
'task.permissionConfirmed': '权限已确认',
|
|
1139
1179
|
'hint.pushToGitea': '卡片进入「已完成」即关闭其 issue。看板不设置任何标签:阶段从分支和 pull request 上已经看得出来。',
|
|
1140
1180
|
'hint.webhookSecretRef': '保存 Gitea webhook 密钥的 DSH 凭据的名称。留空则只剩轮询。',
|
|
1141
1181
|
'hint.syncIntervalSec': '填 0 关闭核对。小于十五秒会被提到十五秒。',
|
|
@@ -1411,6 +1451,12 @@ window.__ModuleLoader__.load({
|
|
|
1411
1451
|
{ field: 'webhookSecretRef', kind: 'text', group: 'sync' },
|
|
1412
1452
|
{ field: 'worktreeIsolation', kind: 'bool', group: 'general' },
|
|
1413
1453
|
{ field: 'worktreeRoot', kind: 'text', group: 'general' },
|
|
1454
|
+
{ field: 'cronEnabled', kind: 'bool', group: 'sync' },
|
|
1455
|
+
{ field: 'preventIdleSleep', kind: 'bool', group: 'general' },
|
|
1456
|
+
{ field: 'progressDumpEnabled', kind: 'bool', group: 'general' },
|
|
1457
|
+
{ field: 'permissionGateEnabled', kind: 'bool', group: 'general' },
|
|
1458
|
+
{ field: 'sessionDefaultPermission', kind: 'text', group: 'general' },
|
|
1459
|
+
{ field: 'announceToAgent', kind: 'bool', group: 'general' },
|
|
1414
1460
|
]
|
|
1415
1461
|
|
|
1416
1462
|
function hintKey(spec) {
|
|
@@ -1632,6 +1678,8 @@ window.__ModuleLoader__.load({
|
|
|
1632
1678
|
waiting ? React.createElement('span', { className: 'dkb-waitDot' }) : null,
|
|
1633
1679
|
waiting ? React.createElement('span', { className: 'dkb-waiting' }, t('board.waiting')) : null,
|
|
1634
1680
|
taskRef(task, t),
|
|
1681
|
+
task.cron ? React.createElement('span', { className: 'dkb-tag', title: task.cron }, '⏰ cron') : null,
|
|
1682
|
+
task.progressDump ? React.createElement('span', { className: 'dkb-tag', title: 'PROGRESSDUMP' }, '📋 dump') : null,
|
|
1635
1683
|
(task.labels || []).map((l) => React.createElement('span', {
|
|
1636
1684
|
className: 'dkb-tag', key: l, style: tagStyle(task, l),
|
|
1637
1685
|
}, l))),
|
|
@@ -1784,6 +1832,7 @@ window.__ModuleLoader__.load({
|
|
|
1784
1832
|
const [editPriority, setEditPriority] = React.useState('')
|
|
1785
1833
|
const [editLabels, setEditLabels] = React.useState('')
|
|
1786
1834
|
const [editDue, setEditDue] = React.useState('')
|
|
1835
|
+
const [editCron, setEditCron] = React.useState('')
|
|
1787
1836
|
const [overColumn, setOverColumn] = React.useState('')
|
|
1788
1837
|
const [model, setModel] = React.useState('')
|
|
1789
1838
|
const [provider, setProvider] = React.useState('')
|
|
@@ -1997,6 +2046,7 @@ window.__ModuleLoader__.load({
|
|
|
1997
2046
|
setEditDue(typeof task?.dueAt === 'number' && task.dueAt > 0
|
|
1998
2047
|
? new Date(task.dueAt).toISOString().slice(0, 10)
|
|
1999
2048
|
: '')
|
|
2049
|
+
setEditCron(typeof task?.cron === 'string' ? task.cron : '')
|
|
2000
2050
|
api('/task/' + encodeURIComponent(task.id) + '/body')
|
|
2001
2051
|
.then((out) => setBody((out && out.blocks) || []))
|
|
2002
2052
|
.catch(() => setBody([]))
|
|
@@ -2528,6 +2578,25 @@ window.__ModuleLoader__.load({
|
|
|
2528
2578
|
t('panel.worktree', { path: openTask.worktree })
|
|
2529
2579
|
+ (openTask.branch ? ' (' + openTask.branch + ')' : ''))
|
|
2530
2580
|
: null,
|
|
2581
|
+
openTask.permission && !openTask.permissionConfirmedAt
|
|
2582
|
+
? React.createElement('div', {
|
|
2583
|
+
className: 'dkb-warnBox',
|
|
2584
|
+
style: { margin: '4px 0', padding: 10, background: 'var(--dsw-alias-state-warn-bg, #fff3cd)', border: '1px solid var(--dsw-alias-state-warn-border, #ffeeba)', borderRadius: 6 },
|
|
2585
|
+
},
|
|
2586
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-state-warn-primary, #856404)' } },
|
|
2587
|
+
t('task.permissionGateWarning', { permission: openTask.permission })),
|
|
2588
|
+
React.createElement('button', {
|
|
2589
|
+
type: 'button', className: 'dkb-save',
|
|
2590
|
+
onClick: async () => {
|
|
2591
|
+
try {
|
|
2592
|
+
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/confirm-permission', { method: 'POST' })
|
|
2593
|
+
if (out && out.task) setOpenTask(out.task)
|
|
2594
|
+
await load(board)
|
|
2595
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2596
|
+
},
|
|
2597
|
+
}, t('task.confirmPermission')),
|
|
2598
|
+
)
|
|
2599
|
+
: null,
|
|
2531
2600
|
// Пустое тело — отсутствие раздела, а не пустой раздел с заголовком.
|
|
2532
2601
|
body === null
|
|
2533
2602
|
? React.createElement('p', { className: 'dkb-note' }, t('panel.bodyLoading'))
|
|
@@ -2562,6 +2631,18 @@ window.__ModuleLoader__.load({
|
|
|
2562
2631
|
onChange: (e) => setEditDue(e.target.value),
|
|
2563
2632
|
}),
|
|
2564
2633
|
),
|
|
2634
|
+
React.createElement('div', { className: 'dkb-field' },
|
|
2635
|
+
React.createElement('span', { className: 'dkb-label' }, t('task.cron')),
|
|
2636
|
+
React.createElement('input', {
|
|
2637
|
+
className: 'dkb-input', value: editCron,
|
|
2638
|
+
placeholder: '* * * * *',
|
|
2639
|
+
onChange: (e) => setEditCron(e.target.value),
|
|
2640
|
+
}),
|
|
2641
|
+
openTask.nextRunAt
|
|
2642
|
+
? React.createElement('p', { className: 'dkb-hint' },
|
|
2643
|
+
t('task.nextRun', { date: new Date(openTask.nextRunAt).toLocaleString() }))
|
|
2644
|
+
: null,
|
|
2645
|
+
),
|
|
2565
2646
|
React.createElement('button', {
|
|
2566
2647
|
type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
|
|
2567
2648
|
onClick: async () => {
|
|
@@ -2574,7 +2655,7 @@ window.__ModuleLoader__.load({
|
|
|
2574
2655
|
: new Date(editDue + 'T00:00:00').getTime()
|
|
2575
2656
|
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/update', {
|
|
2576
2657
|
method: 'POST',
|
|
2577
|
-
body: JSON.stringify({ priority: editPriority, labels, dueAt }),
|
|
2658
|
+
body: JSON.stringify({ priority: editPriority, labels, dueAt, cron: editCron }),
|
|
2578
2659
|
})
|
|
2579
2660
|
setOpenTask(out.task)
|
|
2580
2661
|
await load(board)
|
|
@@ -2582,6 +2663,47 @@ window.__ModuleLoader__.load({
|
|
|
2582
2663
|
},
|
|
2583
2664
|
}, t('panel.saveEdit')),
|
|
2584
2665
|
),
|
|
2666
|
+
openTask.progressDump
|
|
2667
|
+
? React.createElement('div', null,
|
|
2668
|
+
React.createElement('div', { className: 'dkb-group' }, t('task.progressDump')),
|
|
2669
|
+
React.createElement('pre', {
|
|
2670
|
+
style: {
|
|
2671
|
+
background: 'var(--dsw-alias-bg-layer-1, #f6f8fa)', padding: 8, borderRadius: 6, fontSize: 12,
|
|
2672
|
+
whiteSpace: 'pre-wrap', maxHeight: 150, overflowY: 'auto', margin: 0,
|
|
2673
|
+
},
|
|
2674
|
+
}, openTask.progressDump),
|
|
2675
|
+
)
|
|
2676
|
+
: null,
|
|
2677
|
+
openTask.runs && openTask.runs.length
|
|
2678
|
+
? React.createElement('div', null,
|
|
2679
|
+
React.createElement('div', { className: 'dkb-group' }, t('task.runs')),
|
|
2680
|
+
React.createElement('ul', { className: 'dkb-log' }, openTask.runs.map((r) => React.createElement('li', {
|
|
2681
|
+
className: 'dkb-logRow', key: r.id,
|
|
2682
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
|
2683
|
+
},
|
|
2684
|
+
React.createElement('span', null,
|
|
2685
|
+
(r.result === 'succeeded' ? '✓ ' : r.result === 'failed' ? '✕ ' : '▸ ')
|
|
2686
|
+
+ new Date(r.startedAt).toLocaleTimeString()
|
|
2687
|
+
+ (r.model ? ' · ' + r.model : '')
|
|
2688
|
+
+ (r.error ? ' · ' + r.error : '')
|
|
2689
|
+
),
|
|
2690
|
+
r.sessionId
|
|
2691
|
+
? React.createElement('button', {
|
|
2692
|
+
type: 'button',
|
|
2693
|
+
className: 'dkb-discard',
|
|
2694
|
+
style: { padding: '2px 6px', fontSize: 11 },
|
|
2695
|
+
onClick: () => {
|
|
2696
|
+
if (!openSession(props.ctx, r.sessionId)) {
|
|
2697
|
+
setError(t('error.sessionNotOpened', { id: r.sessionId }))
|
|
2698
|
+
return
|
|
2699
|
+
}
|
|
2700
|
+
setOpenTask(null)
|
|
2701
|
+
if (props.onClose) props.onClose()
|
|
2702
|
+
},
|
|
2703
|
+
}, t('panel.openChat'))
|
|
2704
|
+
: null,
|
|
2705
|
+
))))
|
|
2706
|
+
: null,
|
|
2585
2707
|
// Взять и отдать — одна кнопка в двух состояниях: держать рядом
|
|
2586
2708
|
// «взять» и «отказаться» значит предлагать бессмысленное действие.
|
|
2587
2709
|
React.createElement('button', {
|
package/lib/config.js
CHANGED
|
@@ -73,6 +73,12 @@ export const CONFIG_DEFAULTS = {
|
|
|
73
73
|
pushToGitea: true,
|
|
74
74
|
worktreeIsolation: true,
|
|
75
75
|
worktreeRoot: '',
|
|
76
|
+
cronEnabled: false,
|
|
77
|
+
preventIdleSleep: false,
|
|
78
|
+
progressDumpEnabled: false,
|
|
79
|
+
permissionGateEnabled: false,
|
|
80
|
+
sessionDefaultPermission: 'read-only',
|
|
81
|
+
announceToAgent: true,
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/** Пояснения к полям; повторно используются схемой и карточкой настроек. */
|
|
@@ -95,6 +101,12 @@ export const CONFIG_HINTS = {
|
|
|
95
101
|
pushToGitea: 'Закрывать issue в Gitea, когда карточка уходит в колонку «Выполнено».',
|
|
96
102
|
worktreeIsolation: 'Изолировать сессии задач в отдельные git worktrees для предотвращения коллизий.',
|
|
97
103
|
worktreeRoot: 'Каталог для воркдеревьев задач. Пусто — $DSH_HOME/worktrees или .worktrees проекта.',
|
|
104
|
+
cronEnabled: 'Включить фоновый запуск задач по расписанию cron на стороне хоста.',
|
|
105
|
+
preventIdleSleep: 'Запретить переход хоста в спящий режим, пока есть активные сессии задач.',
|
|
106
|
+
progressDumpEnabled: 'Включить поддержку фиксации срезов PROGRESSDUMP для передачи эстафеты между сессиями.',
|
|
107
|
+
permissionGateEnabled: 'Требовать подтверждения человеком для запуска задач с повышенными правами.',
|
|
108
|
+
sessionDefaultPermission: 'Базовый уровень прав без подтверждения. Права выше этого требуют одобрения.',
|
|
109
|
+
announceToAgent: 'Добавлять секцию о доске задач в системный промпт агентов.',
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
/** Поле настроек с пределом числа карточек; задан не у всех колонок. */
|
package/lib/cron.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// dsh-kanban: парсер 5-позиционного cron и вычисление следующего запуска.
|
|
2
|
+
// Без внешних зависимостей: чистые функции над датами и числами.
|
|
3
|
+
|
|
4
|
+
const FIELD_RANGES = [
|
|
5
|
+
[0, 59], // минуты
|
|
6
|
+
[0, 23], // часы
|
|
7
|
+
[1, 31], // дни месяца
|
|
8
|
+
[1, 12], // месяцы
|
|
9
|
+
[0, 7], // дни недели (0 и 7 — воскресенье)
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
function parseField(token, min, max, targetSet) {
|
|
13
|
+
if (token === '*') {
|
|
14
|
+
for (let i = min; i <= max; i++) targetSet.add(i)
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const part of token.split(',')) {
|
|
19
|
+
if (part === '') return false
|
|
20
|
+
|
|
21
|
+
if (part.includes('/')) {
|
|
22
|
+
const [rangePart, stepStr] = part.split('/')
|
|
23
|
+
const step = Number(stepStr)
|
|
24
|
+
if (!Number.isInteger(step) || step <= 0) return false
|
|
25
|
+
|
|
26
|
+
let start = min
|
|
27
|
+
let end = max
|
|
28
|
+
if (rangePart !== '*' && rangePart !== '') {
|
|
29
|
+
if (rangePart.includes('-')) {
|
|
30
|
+
const [sStr, eStr] = rangePart.split('-')
|
|
31
|
+
start = Number(sStr)
|
|
32
|
+
end = Number(eStr)
|
|
33
|
+
} else {
|
|
34
|
+
start = Number(rangePart)
|
|
35
|
+
end = max
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
for (let i = start; i <= end; i += step) targetSet.add(i)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (part.includes('-')) {
|
|
46
|
+
const [sStr, eStr] = part.split('-')
|
|
47
|
+
const start = Number(sStr)
|
|
48
|
+
const end = Number(eStr)
|
|
49
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
for (let i = start; i <= end; i++) targetSet.add(i)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const val = Number(part)
|
|
57
|
+
if (!Number.isInteger(val) || val < min || val > max) return false
|
|
58
|
+
targetSet.add(val)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return targetSet.size > 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Разобрать 5-позиционное выражение cron: "минута час день месяц день_недели".
|
|
66
|
+
* @param {string} expr
|
|
67
|
+
* @returns {object|null} распарсенное расписание или null, если выражение невалидно
|
|
68
|
+
*/
|
|
69
|
+
export function parseCron(expr) {
|
|
70
|
+
if (typeof expr !== 'string') return null
|
|
71
|
+
const fields = expr.trim().split(/\s+/)
|
|
72
|
+
if (fields.length !== 5) return null
|
|
73
|
+
|
|
74
|
+
const sets = []
|
|
75
|
+
for (let index = 0; index < 5; index++) {
|
|
76
|
+
const [min, max] = FIELD_RANGES[index]
|
|
77
|
+
const set = new Set()
|
|
78
|
+
if (!parseField(fields[index], min, max, set)) return null
|
|
79
|
+
sets.push(set)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const weekdays = new Set()
|
|
83
|
+
for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
minutes: sets[0],
|
|
87
|
+
hours: sets[1],
|
|
88
|
+
days: sets[2],
|
|
89
|
+
months: sets[3],
|
|
90
|
+
weekdays,
|
|
91
|
+
dayWildcard: fields[2] === '*',
|
|
92
|
+
weekdayWildcard: fields[4] === '*',
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Вычислить время следующего запуска (в миллисекундах epoch) строго после `fromMs`.
|
|
98
|
+
* Поиск ограничен 5 годами вперед, чтобы избежать бесконечного цикла.
|
|
99
|
+
* @param {string|object} exprOrSchedule
|
|
100
|
+
* @param {number} [fromMs]
|
|
101
|
+
* @returns {number|undefined} время следующего запуска или undefined
|
|
102
|
+
*/
|
|
103
|
+
export function computeNextRun(exprOrSchedule, fromMs = Date.now()) {
|
|
104
|
+
const schedule = typeof exprOrSchedule === 'string' ? parseCron(exprOrSchedule) : exprOrSchedule
|
|
105
|
+
if (!schedule) return undefined
|
|
106
|
+
|
|
107
|
+
// Стартуем со следующей целой минуты
|
|
108
|
+
const current = new Date(fromMs)
|
|
109
|
+
current.setSeconds(0, 0)
|
|
110
|
+
current.setMinutes(current.getMinutes() + 1)
|
|
111
|
+
|
|
112
|
+
const limitMs = fromMs + 5 * 365 * 24 * 60 * 60 * 1000
|
|
113
|
+
|
|
114
|
+
while (current.getTime() <= limitMs) {
|
|
115
|
+
const month = current.getMonth() + 1
|
|
116
|
+
if (!schedule.months.has(month)) {
|
|
117
|
+
current.setMonth(current.getMonth() + 1, 1)
|
|
118
|
+
current.setHours(0, 0, 0, 0)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const dayOfMonth = current.getDate()
|
|
123
|
+
const dayOfWeek = current.getDay()
|
|
124
|
+
const dayMatches = schedule.dayWildcard && schedule.weekdayWildcard
|
|
125
|
+
? true
|
|
126
|
+
: schedule.dayWildcard
|
|
127
|
+
? schedule.weekdays.has(dayOfWeek)
|
|
128
|
+
: schedule.weekdayWildcard
|
|
129
|
+
? schedule.days.has(dayOfMonth)
|
|
130
|
+
: schedule.days.has(dayOfMonth) || schedule.weekdays.has(dayOfWeek)
|
|
131
|
+
|
|
132
|
+
if (!dayMatches) {
|
|
133
|
+
current.setDate(current.getDate() + 1)
|
|
134
|
+
current.setHours(0, 0, 0, 0)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const hour = current.getHours()
|
|
139
|
+
if (!schedule.hours.has(hour)) {
|
|
140
|
+
current.setHours(current.getHours() + 1, 0, 0, 0)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const minute = current.getMinutes()
|
|
145
|
+
if (!schedule.minutes.has(minute)) {
|
|
146
|
+
current.setMinutes(current.getMinutes() + 1)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return current.getTime()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
|
|
24
24
|
taskBody,
|
|
25
25
|
taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
|
|
26
|
+
confirmPermission,
|
|
26
27
|
} from './routes.js'
|
|
27
28
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
28
29
|
import {
|
|
29
30
|
runTask, resolveModel, buildStartMessage,
|
|
30
31
|
runBatch, queueTask, unqueueTask, liveSessions,
|
|
32
|
+
PERMISSION_RANKS,
|
|
31
33
|
} from './launcher.js'
|
|
32
34
|
import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
|
|
33
35
|
import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
|
|
@@ -38,6 +40,9 @@ import { handleSessionEvent } from './lifecycle.js'
|
|
|
38
40
|
import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
|
|
39
41
|
import { verifySignature, parseEvent } from './webhook.js'
|
|
40
42
|
import { planOutbound, planAssign, createOutbox } from './outbound.js'
|
|
43
|
+
import { computeNextRun } from './cron.js'
|
|
44
|
+
import { PowerInhibitor } from './power.js'
|
|
45
|
+
import { formatProgressDump, parseProgressDump } from './progress-dump.js'
|
|
41
46
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
42
47
|
|
|
43
48
|
export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
|
|
@@ -197,17 +202,140 @@ export function apply(ctx, config) {
|
|
|
197
202
|
return () => { try { store.close() } catch { /* уже закрыта */ } }
|
|
198
203
|
}, 'dsh-kanban: хранилище задач')
|
|
199
204
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
// Кроссплатформенный ингибитор сна системы во время работы задач
|
|
206
|
+
const powerInhibitor = new PowerInhibitor({
|
|
207
|
+
platform: process.platform,
|
|
208
|
+
pid: process.pid,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const syncPower = () => {
|
|
212
|
+
try {
|
|
213
|
+
const cfg = getConfig()
|
|
214
|
+
powerInhibitor.setEnabled(cfg.preventIdleSleep === true)
|
|
215
|
+
if (store) {
|
|
216
|
+
const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
|
|
217
|
+
powerInhibitor.setReasons(activeCount)
|
|
218
|
+
}
|
|
219
|
+
} catch { /* игнорируем */ }
|
|
220
|
+
}
|
|
221
|
+
rearmers.add(syncPower)
|
|
222
|
+
ctx.effect(() => {
|
|
223
|
+
syncPower()
|
|
224
|
+
return () => { powerInhibitor.destroy() }
|
|
225
|
+
}, 'dsh-kanban: ингибитор сна')
|
|
226
|
+
|
|
227
|
+
// Инъекция секции доски в системный промпт агента
|
|
228
|
+
let disposePromptSection
|
|
229
|
+
const syncPromptSection = () => {
|
|
230
|
+
if (disposePromptSection) {
|
|
231
|
+
try { disposePromptSection() } catch {}
|
|
232
|
+
disposePromptSection = undefined
|
|
233
|
+
}
|
|
234
|
+
const cfg = getConfig()
|
|
235
|
+
const sp = ctx.get('systemPrompt')
|
|
236
|
+
if (cfg.announceToAgent !== false && sp && typeof sp.section === 'function') {
|
|
237
|
+
try {
|
|
238
|
+
disposePromptSection = sp.section({
|
|
239
|
+
name: 'plugin:dsh-kanban',
|
|
240
|
+
order: 200,
|
|
241
|
+
text: 'В системе развернута доска задач dsh-kanban. При работе над задачами вы можете перемещать карточки инструментом board_move и вести чек-лист плана через board_plan. При исчерпании контекста или завершении подэтапа сформируйте блок <<<PROGRESSDUMP\\nЦель: ...\\nПрогресс: ...\\nСледующие шаги: ...\\n>>>PROGRESSDUMP для передачи эстафеты следующей сессии.',
|
|
242
|
+
})
|
|
243
|
+
} catch (err) {
|
|
244
|
+
ctx.logger?.warn?.(`dsh-kanban: не удалось зарегистрировать секцию промпта: ${err?.message}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
rearmers.add(syncPromptSection)
|
|
249
|
+
ctx.effect(() => {
|
|
250
|
+
syncPromptSection()
|
|
251
|
+
return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
|
|
252
|
+
}, 'dsh-kanban: секция системного промпта')
|
|
253
|
+
|
|
254
|
+
// Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
|
|
203
255
|
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
204
256
|
try {
|
|
205
|
-
|
|
257
|
+
const text = event?.content?.[0]?.text ?? event?.text ?? ''
|
|
258
|
+
handleSessionEvent({
|
|
259
|
+
store,
|
|
260
|
+
sessionId: String(session?.id ?? ''),
|
|
261
|
+
type: event?.type,
|
|
262
|
+
text,
|
|
263
|
+
error: event?.error,
|
|
264
|
+
})
|
|
265
|
+
syncPower()
|
|
206
266
|
} catch (error) {
|
|
207
267
|
ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
|
|
208
268
|
}
|
|
209
269
|
}), 'dsh-kanban: события сессии')
|
|
210
270
|
|
|
271
|
+
// Фоновый шедулер cron для задач
|
|
272
|
+
ctx.effect(() => {
|
|
273
|
+
let cronTimer
|
|
274
|
+
const armCron = () => {
|
|
275
|
+
clearInterval(cronTimer)
|
|
276
|
+
cronTimer = undefined
|
|
277
|
+
if (getConfig().cronEnabled !== true) return
|
|
278
|
+
cronTimer = setInterval(async () => {
|
|
279
|
+
try {
|
|
280
|
+
if (!store) return
|
|
281
|
+
const now = Date.now()
|
|
282
|
+
const dueTasks = store.listDueTasks(now)
|
|
283
|
+
for (const task of dueTasks) {
|
|
284
|
+
if (task.column === 'in-progress') {
|
|
285
|
+
const next = computeNextRun(task.cron, now)
|
|
286
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (getConfig().permissionGateEnabled) {
|
|
290
|
+
const perm = task.permission || 'read-only'
|
|
291
|
+
const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
|
|
292
|
+
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
|
|
293
|
+
const next = computeNextRun(task.cron, now)
|
|
294
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
295
|
+
store.addTransition({
|
|
296
|
+
taskId: task.id,
|
|
297
|
+
fromCol: task.column,
|
|
298
|
+
toCol: task.column,
|
|
299
|
+
source: 'tool',
|
|
300
|
+
detail: 'cron: запуск отклонён (требуется подтверждение повышенных прав)',
|
|
301
|
+
})
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const picked = resolveModel({
|
|
306
|
+
requested: { model: task.model, provider: task.provider },
|
|
307
|
+
fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
308
|
+
})
|
|
309
|
+
if (picked.model) {
|
|
310
|
+
await runTask({
|
|
311
|
+
agents: ctx.agents,
|
|
312
|
+
store,
|
|
313
|
+
task,
|
|
314
|
+
config: getConfig(),
|
|
315
|
+
provider: picked.provider,
|
|
316
|
+
model: picked.model,
|
|
317
|
+
mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
|
|
318
|
+
createMessage: createUserMessage,
|
|
319
|
+
logger: ctx.logger,
|
|
320
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
321
|
+
claimAs: 'cron',
|
|
322
|
+
})
|
|
323
|
+
syncPower()
|
|
324
|
+
}
|
|
325
|
+
const next = computeNextRun(task.cron, now)
|
|
326
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
ctx.logger?.warn?.(`dsh-kanban: ошибка шедулера cron: ${err?.message}`)
|
|
330
|
+
}
|
|
331
|
+
}, 30_000)
|
|
332
|
+
if (typeof cronTimer.unref === 'function') cronTimer.unref()
|
|
333
|
+
}
|
|
334
|
+
armCron()
|
|
335
|
+
rearmers.add(armCron)
|
|
336
|
+
return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
|
|
337
|
+
}, 'dsh-kanban: шедулер cron')
|
|
338
|
+
|
|
211
339
|
// Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
|
|
212
340
|
// перекрытие запусков не допускается: медленный инстанс иначе копил бы
|
|
213
341
|
// очередь сверок вместо одной идущей.
|
|
@@ -646,7 +774,7 @@ export function apply(ctx, config) {
|
|
|
646
774
|
fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
647
775
|
})
|
|
648
776
|
if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
|
|
649
|
-
|
|
777
|
+
const started = await runTask({
|
|
650
778
|
agents: ctx.agents,
|
|
651
779
|
store,
|
|
652
780
|
task,
|
|
@@ -664,7 +792,9 @@ export function apply(ctx, config) {
|
|
|
664
792
|
permissions: ctx.get('permissionPresets'),
|
|
665
793
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
666
794
|
claimAs: await whoAmI(),
|
|
667
|
-
})
|
|
795
|
+
})
|
|
796
|
+
syncPower()
|
|
797
|
+
reply(res, started)
|
|
668
798
|
} catch (error) {
|
|
669
799
|
ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
|
|
670
800
|
// Отказ по уровню доступа называется своим именем: «не удалось» на
|
|
@@ -736,11 +866,28 @@ export function apply(ctx, config) {
|
|
|
736
866
|
})
|
|
737
867
|
return
|
|
738
868
|
}
|
|
869
|
+
if (req.method === 'POST' && action === 'confirm-permission') {
|
|
870
|
+
reply(res, confirmPermission({ store, id }))
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
if (req.method === 'POST' && action === 'progress-dump') {
|
|
874
|
+
const task = store.getTask(id)
|
|
875
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
876
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
877
|
+
const updated = store.updateTask(id, { progressDump: formatted })
|
|
878
|
+
reply(res, { task: updated })
|
|
879
|
+
return
|
|
880
|
+
}
|
|
739
881
|
if (req.method === 'POST' && action === 'stop') {
|
|
740
882
|
const task = store.getTask(id)
|
|
741
883
|
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
742
884
|
const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
|
|
743
885
|
if (out.acted === 'stopped') {
|
|
886
|
+
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
887
|
+
if (latestRun && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
|
888
|
+
store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
|
|
889
|
+
}
|
|
890
|
+
syncPower()
|
|
744
891
|
// Остановка — событие, и в журнале ей место наравне с переносом:
|
|
745
892
|
// иначе прерванная работа выглядит просто оборвавшейся сама.
|
|
746
893
|
store.addTransition({
|
package/lib/launcher.js
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
// приходят параметрами. Так модуль проверяется без харнесса и без сети.
|
|
14
14
|
|
|
15
15
|
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
16
17
|
import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
|
|
18
|
+
import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
|
|
19
|
+
|
|
20
|
+
export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* Хвост своей задачи — УСЛОВИЕ, а не приказ.
|
|
@@ -291,11 +295,35 @@ export async function runTask({
|
|
|
291
295
|
agentPreset, permission, permissions, claimAs,
|
|
292
296
|
gitRunner, workspaceRegistry,
|
|
293
297
|
}) {
|
|
298
|
+
if (config?.permissionGateEnabled) {
|
|
299
|
+
const perm = permission || task.permission || 'read-only'
|
|
300
|
+
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
301
|
+
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
|
|
302
|
+
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
303
|
+
const err = new Error('Требуется подтверждение прав')
|
|
304
|
+
err.key = 'permission-unconfirmed'
|
|
305
|
+
err.status = 403
|
|
306
|
+
throw err
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
294
311
|
const taken = await obtainAgent({
|
|
295
312
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
296
313
|
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
297
314
|
})
|
|
298
315
|
|
|
316
|
+
// Фиксируем запуск в истории задачи
|
|
317
|
+
if (typeof store.recordTaskRun === 'function') {
|
|
318
|
+
store.recordTaskRun(task.id, {
|
|
319
|
+
id: randomUUID(),
|
|
320
|
+
sessionId: taken.sessionId,
|
|
321
|
+
startedAt: Date.now(),
|
|
322
|
+
model,
|
|
323
|
+
provider,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
299
327
|
await taken.agent.whenIdle()
|
|
300
328
|
taken.agent.followup(createMessage({
|
|
301
329
|
// Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
|
|
@@ -309,6 +337,7 @@ export async function runTask({
|
|
|
309
337
|
sessionId: taken.sessionId,
|
|
310
338
|
provider,
|
|
311
339
|
model,
|
|
340
|
+
...(permission ? { permission } : {}),
|
|
312
341
|
...(taken.worktree ? { worktree: taken.worktree } : {}),
|
|
313
342
|
...(taken.branch ? { branch: taken.branch } : {}),
|
|
314
343
|
...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
|
|
@@ -359,7 +388,20 @@ export async function runTask({
|
|
|
359
388
|
/** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
|
|
360
389
|
function messageFor(task, config, text) {
|
|
361
390
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
362
|
-
|
|
391
|
+
const base = own !== '' ? own : buildStartMessage(task, config)
|
|
392
|
+
if (config?.progressDumpEnabled && task.progressDump) {
|
|
393
|
+
let parsed
|
|
394
|
+
try {
|
|
395
|
+
parsed = typeof task.progressDump === 'string' && task.progressDump.startsWith('{')
|
|
396
|
+
? JSON.parse(task.progressDump)
|
|
397
|
+
: parseProgressDump(task.progressDump)?.dump
|
|
398
|
+
} catch {
|
|
399
|
+
parsed = parseProgressDump(task.progressDump)?.dump
|
|
400
|
+
}
|
|
401
|
+
const preamble = formatHandoverPreamble(parsed)
|
|
402
|
+
if (preamble) return `${preamble}${base}`
|
|
403
|
+
}
|
|
404
|
+
return base
|
|
363
405
|
}
|
|
364
406
|
|
|
365
407
|
/**
|
package/lib/lifecycle.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// делать с очередным событием.
|
|
10
10
|
|
|
11
11
|
import { waitingFromEvent } from './transitions.js'
|
|
12
|
+
import { parseProgressDump, formatProgressDump } from './progress-dump.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Обработать событие сессии.
|
|
@@ -17,14 +18,42 @@ import { waitingFromEvent } from './transitions.js'
|
|
|
17
18
|
* большинство — для них здесь ничего не происходит, и это штатный ход, а не
|
|
18
19
|
* пропуск.
|
|
19
20
|
*
|
|
20
|
-
* @returns {{taskId: string, waiting
|
|
21
|
+
* @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
|
|
21
22
|
*/
|
|
22
|
-
export function handleSessionEvent({ store, sessionId, type }) {
|
|
23
|
-
const next = waitingFromEvent(type)
|
|
24
|
-
if (next === undefined) return undefined
|
|
25
|
-
|
|
23
|
+
export function handleSessionEvent({ store, sessionId, type, text, error }) {
|
|
26
24
|
const task = store.findTaskBySession(sessionId)
|
|
27
25
|
if (task === undefined) return undefined
|
|
26
|
+
|
|
27
|
+
// Если в ответе пришёл PROGRESSDUMP — обновляем срез в задаче
|
|
28
|
+
if (typeof text === 'string' && text.includes('<<<PROGRESSDUMP')) {
|
|
29
|
+
const parsed = parseProgressDump(text)
|
|
30
|
+
if (parsed.ok) {
|
|
31
|
+
store.updateTask(task.id, { progressDump: formatProgressDump(parsed.dump) })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Обновляем исход запуска в истории runs
|
|
36
|
+
if (type === 'turn/end' || type === 'turn/completed') {
|
|
37
|
+
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
38
|
+
if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
|
39
|
+
store.updateTaskRun(task.id, latestRun.id, {
|
|
40
|
+
endedAt: Date.now(),
|
|
41
|
+
result: 'succeeded',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
} else if (type === 'turn/error' || type === 'session/error') {
|
|
45
|
+
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
46
|
+
if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
|
47
|
+
store.updateTaskRun(task.id, latestRun.id, {
|
|
48
|
+
endedAt: Date.now(),
|
|
49
|
+
result: 'failed',
|
|
50
|
+
error: typeof error === 'string' ? error : error?.message || 'error',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = waitingFromEvent(type)
|
|
56
|
+
if (next === undefined) return undefined
|
|
28
57
|
if (task.waiting === next) return undefined
|
|
29
58
|
|
|
30
59
|
store.updateTask(task.id, { waiting: next })
|
package/lib/power.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// dsh-kanban: кроссплатформенный ингибитор сна системы во время работы задач.
|
|
2
|
+
// Поддерживает Windows (PowerShell + Win32 API), Linux (systemd-inhibit) и macOS (caffeinate).
|
|
3
|
+
|
|
4
|
+
import { spawn as defaultSpawn } from 'node:child_process'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
const WINDOWS_HELPER = [
|
|
8
|
+
'$source = @\'',
|
|
9
|
+
'using System;',
|
|
10
|
+
'using System.Runtime.InteropServices;',
|
|
11
|
+
'public static class DshExecutionState {',
|
|
12
|
+
' [DllImport("kernel32.dll", SetLastError = true)]',
|
|
13
|
+
' public static extern uint SetThreadExecutionState(uint flags);',
|
|
14
|
+
'}',
|
|
15
|
+
'\'@',
|
|
16
|
+
'Add-Type -TypeDefinition $source',
|
|
17
|
+
"$continuous = [Convert]::ToUInt32('80000000', 16)",
|
|
18
|
+
'$systemRequired = [uint32]0x00000001',
|
|
19
|
+
'try {',
|
|
20
|
+
' $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)',
|
|
21
|
+
' if ($result -eq 0) { throw \'SetThreadExecutionState failed\' }',
|
|
22
|
+
' [Console]::Out.WriteLine(\'READY\')',
|
|
23
|
+
' [Console]::Out.Flush()',
|
|
24
|
+
' while ([Console]::In.ReadLine() -ne $null) { }',
|
|
25
|
+
'} finally {',
|
|
26
|
+
' [void][DshExecutionState]::SetThreadExecutionState($continuous)',
|
|
27
|
+
'}',
|
|
28
|
+
].join('\n')
|
|
29
|
+
|
|
30
|
+
const LINUX_HELPER = "process.stdout.write('READY\\n'); process.stdin.resume();"
|
|
31
|
+
|
|
32
|
+
const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit']
|
|
33
|
+
|
|
34
|
+
export class PowerInhibitor {
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.platform = options.platform ?? process.platform
|
|
37
|
+
this.pid = options.pid ?? process.pid
|
|
38
|
+
this.spawn = options.spawn ?? defaultSpawn
|
|
39
|
+
this.exists = options.exists ?? existsSync
|
|
40
|
+
this.enabled = false
|
|
41
|
+
this.reasons = 0
|
|
42
|
+
this.child = null
|
|
43
|
+
this.phase = 'disabled'
|
|
44
|
+
this.lastError = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setEnabled(enabled) {
|
|
48
|
+
this.enabled = Boolean(enabled)
|
|
49
|
+
this.sync()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setReasons(count) {
|
|
53
|
+
this.reasons = Math.max(0, Number(count) || 0)
|
|
54
|
+
this.sync()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
acquire() {
|
|
58
|
+
this.reasons++
|
|
59
|
+
this.sync()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
release() {
|
|
63
|
+
this.reasons = Math.max(0, this.reasons - 1)
|
|
64
|
+
this.sync()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
sync() {
|
|
68
|
+
const shouldHold = this.enabled && this.reasons > 0
|
|
69
|
+
if (shouldHold && !this.child) {
|
|
70
|
+
this.startHelper()
|
|
71
|
+
} else if (!shouldHold && this.child) {
|
|
72
|
+
this.stopHelper()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
startHelper() {
|
|
77
|
+
this.stopHelper()
|
|
78
|
+
try {
|
|
79
|
+
if (this.platform === 'win32') {
|
|
80
|
+
const psPath = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
|
|
81
|
+
this.child = this.spawn(psPath, ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
|
|
82
|
+
shell: false,
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
|
+
})
|
|
86
|
+
} else if (this.platform === 'darwin') {
|
|
87
|
+
this.child = this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
|
|
88
|
+
shell: false,
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
91
|
+
})
|
|
92
|
+
} else if (this.platform === 'linux') {
|
|
93
|
+
const bin = LINUX_INHIBIT_PATHS.find((p) => this.exists(p))
|
|
94
|
+
if (!bin) {
|
|
95
|
+
this.phase = 'unsupported'
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
this.child = this.spawn(
|
|
99
|
+
bin,
|
|
100
|
+
['--what=idle', '--who=dsh-kanban', '--why=Task running', 'node', '-e', LINUX_HELPER],
|
|
101
|
+
{ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
102
|
+
)
|
|
103
|
+
} else {
|
|
104
|
+
this.phase = 'unsupported'
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.phase = 'running'
|
|
109
|
+
this.lastError = null
|
|
110
|
+
|
|
111
|
+
this.child.on('error', (err) => {
|
|
112
|
+
this.lastError = err?.message
|
|
113
|
+
this.phase = 'error'
|
|
114
|
+
this.child = null
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
this.child.on('exit', () => {
|
|
118
|
+
this.child = null
|
|
119
|
+
if (this.enabled && this.reasons > 0) {
|
|
120
|
+
this.phase = 'disabled'
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.lastError = err?.message
|
|
125
|
+
this.phase = 'error'
|
|
126
|
+
this.child = null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stopHelper() {
|
|
131
|
+
if (this.child) {
|
|
132
|
+
try {
|
|
133
|
+
if (this.child.stdin && !this.child.stdin.destroyed) {
|
|
134
|
+
this.child.stdin.end()
|
|
135
|
+
}
|
|
136
|
+
this.child.kill()
|
|
137
|
+
} catch {}
|
|
138
|
+
this.child = null
|
|
139
|
+
}
|
|
140
|
+
this.phase = 'disabled'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
snapshot() {
|
|
144
|
+
return {
|
|
145
|
+
enabled: this.enabled,
|
|
146
|
+
reasons: this.reasons,
|
|
147
|
+
phase: this.phase,
|
|
148
|
+
lastError: this.lastError,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
destroy() {
|
|
153
|
+
this.enabled = false
|
|
154
|
+
this.reasons = 0
|
|
155
|
+
this.stopHelper()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
|
|
2
|
+
// Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
|
|
3
|
+
|
|
4
|
+
export const BEGIN_MARKER = '<<<PROGRESSDUMP'
|
|
5
|
+
export const END_MARKER = '>>>PROGRESSDUMP'
|
|
6
|
+
export const FIELD_BYTE_LIMIT = 8 * 1024
|
|
7
|
+
export const REDACTED = '[REDACTED]'
|
|
8
|
+
|
|
9
|
+
const SENSITIVE_PATTERNS = [
|
|
10
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
11
|
+
/Bearer\s+[A-Za-z0-9._~+\/=:-]{8,}/gi,
|
|
12
|
+
/sk-[A-Za-z0-9_-]{8,}/g,
|
|
13
|
+
/ghp_[A-Za-z0-9]{20,}/g,
|
|
14
|
+
/glpat-[A-Za-z0-9_-]{15,}/g,
|
|
15
|
+
/xox[bpars]-[A-Za-z0-9-]{10,}/g,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
function sanitizeText(raw) {
|
|
19
|
+
let text = raw
|
|
20
|
+
let redacted = false
|
|
21
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
22
|
+
if (pattern.test(text)) {
|
|
23
|
+
redacted = true
|
|
24
|
+
text = text.replace(pattern, REDACTED)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Блокируем внедрение слэш-команд харнесса
|
|
28
|
+
text = text.split('\n')
|
|
29
|
+
.map((line) => (line.trim().startsWith('/') ? ('# ' + line) : line))
|
|
30
|
+
.join('\n')
|
|
31
|
+
|
|
32
|
+
return { text, redacted }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncateBytes(str, maxBytes) {
|
|
36
|
+
const buf = Buffer.from(str, 'utf8')
|
|
37
|
+
if (buf.length <= maxBytes) return str
|
|
38
|
+
return buf.subarray(0, maxBytes).toString('utf8')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Разобрать блок PROGRESSDUMP из произвольного текста.
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {{ ok: true, dump: { goal: string, progress: string, next: string, redacted: boolean } } | { ok: false, error: string }}
|
|
45
|
+
*/
|
|
46
|
+
export function parseProgressDump(text) {
|
|
47
|
+
if (typeof text !== 'string') return { ok: false, error: 'text-required' }
|
|
48
|
+
|
|
49
|
+
const startIdx = text.indexOf(BEGIN_MARKER)
|
|
50
|
+
const endIdx = text.indexOf(END_MARKER)
|
|
51
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
52
|
+
return { ok: false, error: 'markers-not-found' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const inner = text.slice(startIdx + BEGIN_MARKER.length, endIdx).trim()
|
|
56
|
+
const lines = inner.split('\n')
|
|
57
|
+
|
|
58
|
+
let currentKey = null
|
|
59
|
+
const sections = { goal: [], progress: [], next: [] }
|
|
60
|
+
|
|
61
|
+
for (const rawLine of lines) {
|
|
62
|
+
const line = rawLine.trim()
|
|
63
|
+
const lower = line.toLowerCase()
|
|
64
|
+
|
|
65
|
+
if (lower.startsWith('цель:') || lower.startsWith('goal:')) {
|
|
66
|
+
currentKey = 'goal'
|
|
67
|
+
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
68
|
+
if (val) sections.goal.push(val)
|
|
69
|
+
} else if (lower.startsWith('прогресс:') || lower.startsWith('progress:')) {
|
|
70
|
+
currentKey = 'progress'
|
|
71
|
+
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
72
|
+
if (val) sections.progress.push(val)
|
|
73
|
+
} else if (lower.startsWith('следующие шаги:') || lower.startsWith('next:') || lower.startsWith('шаги:')) {
|
|
74
|
+
currentKey = 'next'
|
|
75
|
+
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
76
|
+
if (val) sections.next.push(val)
|
|
77
|
+
} else if (currentKey) {
|
|
78
|
+
sections[currentKey].push(rawLine)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let totalRedacted = false
|
|
83
|
+
const result = { goal: '', progress: '', next: '', redacted: false }
|
|
84
|
+
|
|
85
|
+
for (const key of ['goal', 'progress', 'next']) {
|
|
86
|
+
const rawVal = sections[key].join('\n').trim()
|
|
87
|
+
const { text: cleanText, redacted } = sanitizeText(rawVal)
|
|
88
|
+
if (redacted) totalRedacted = true
|
|
89
|
+
result[key] = truncateBytes(cleanText, FIELD_BYTE_LIMIT)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
result.redacted = totalRedacted
|
|
93
|
+
return { ok: true, dump: result }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Сериализовать срез в канонический блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP.
|
|
98
|
+
*/
|
|
99
|
+
export function formatProgressDump(dump) {
|
|
100
|
+
if (!dump) return ''
|
|
101
|
+
return [
|
|
102
|
+
BEGIN_MARKER,
|
|
103
|
+
'Цель:',
|
|
104
|
+
dump.goal || '—',
|
|
105
|
+
'',
|
|
106
|
+
'Прогресс:',
|
|
107
|
+
dump.progress || '—',
|
|
108
|
+
'',
|
|
109
|
+
'Следующие шаги:',
|
|
110
|
+
dump.next || '—',
|
|
111
|
+
END_MARKER,
|
|
112
|
+
].join('\n')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Сформировать аккуратный вводный бриф для новой сессии агента.
|
|
117
|
+
*/
|
|
118
|
+
export function formatHandoverPreamble(dump) {
|
|
119
|
+
if (!dump || (!dump.goal && !dump.progress && !dump.next)) return ''
|
|
120
|
+
return [
|
|
121
|
+
'## 📋 Эстафета задачи (PROGRESSDUMP)',
|
|
122
|
+
'> [!NOTE]',
|
|
123
|
+
'> Предыдущий ход зафиксировал текущее состояние работы:',
|
|
124
|
+
'',
|
|
125
|
+
'**Цель:** ' + (dump.goal || '—'),
|
|
126
|
+
'',
|
|
127
|
+
'**Сделано:** ' + (dump.progress || '—'),
|
|
128
|
+
'',
|
|
129
|
+
'**Следующие шаги:** ' + (dump.next || '—'),
|
|
130
|
+
'',
|
|
131
|
+
'---',
|
|
132
|
+
'',
|
|
133
|
+
].join('\n')
|
|
134
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -14,6 +14,7 @@ import { columnStats, doneWithin, stale } from './metrics.js'
|
|
|
14
14
|
import { isStale } from './time.js'
|
|
15
15
|
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
|
|
16
16
|
import { parseBody } from './markdown.js'
|
|
17
|
+
import { computeNextRun } from './cron.js'
|
|
17
18
|
|
|
18
19
|
/** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
|
|
19
20
|
export const MAX_BODY_BYTES = 256 * 1024
|
|
@@ -183,7 +184,8 @@ export function createTask({ store, input }) {
|
|
|
183
184
|
|
|
184
185
|
/** Правка полей карточки. Колонку и порядок правит только перенос. */
|
|
185
186
|
export function updateTask({ store, id, input }) {
|
|
186
|
-
|
|
187
|
+
const current = store.getTask(id)
|
|
188
|
+
if (current === undefined) return { error: 'task-not-found', status: 404 }
|
|
187
189
|
const patch = {}
|
|
188
190
|
if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
|
|
189
191
|
if (typeof input?.body === 'string') patch.body = input.body
|
|
@@ -197,9 +199,28 @@ export function updateTask({ store, id, input }) {
|
|
|
197
199
|
if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
|
|
198
200
|
patch.dueAt = Math.floor(input.dueAt)
|
|
199
201
|
}
|
|
202
|
+
if (typeof input?.cron === 'string') {
|
|
203
|
+
patch.cron = input.cron.trim()
|
|
204
|
+
patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
|
|
205
|
+
}
|
|
206
|
+
if (typeof input?.progressDump === 'string') {
|
|
207
|
+
patch.progressDump = input.progressDump
|
|
208
|
+
}
|
|
209
|
+
if (typeof input?.permission === 'string') {
|
|
210
|
+
patch.permission = input.permission
|
|
211
|
+
if (input.permission !== current.permission) {
|
|
212
|
+
patch.permissionConfirmedAt = 0
|
|
213
|
+
}
|
|
214
|
+
}
|
|
200
215
|
return { task: store.updateTask(id, patch) }
|
|
201
216
|
}
|
|
202
217
|
|
|
218
|
+
export function confirmPermission({ store, id }) {
|
|
219
|
+
const task = store.getTask(id)
|
|
220
|
+
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
221
|
+
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
/** Известные приоритеты. Пустой — «без приоритета». */
|
|
204
225
|
export const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
205
226
|
|
package/lib/store.js
CHANGED
|
@@ -40,7 +40,14 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
40
40
|
labels TEXT NOT NULL DEFAULT '[]',
|
|
41
41
|
branch TEXT,
|
|
42
42
|
worktree TEXT,
|
|
43
|
-
workspaceId
|
|
43
|
+
workspaceId TEXT NOT NULL DEFAULT '',
|
|
44
|
+
cron TEXT NOT NULL DEFAULT '',
|
|
45
|
+
nextRunAt INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
lastRunAt INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
progressDump TEXT NOT NULL DEFAULT '',
|
|
48
|
+
permission TEXT NOT NULL DEFAULT '',
|
|
49
|
+
permissionConfirmedAt INTEGER NOT NULL DEFAULT 0,
|
|
50
|
+
runs TEXT NOT NULL DEFAULT '[]',
|
|
44
51
|
sessionId TEXT,
|
|
45
52
|
model TEXT,
|
|
46
53
|
provider TEXT,
|
|
@@ -85,6 +92,7 @@ const WRITABLE = [
|
|
|
85
92
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
86
93
|
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
87
94
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
95
|
+
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'permissionConfirmedAt', 'runs',
|
|
88
96
|
]
|
|
89
97
|
|
|
90
98
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -92,7 +100,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
|
|
|
92
100
|
|
|
93
101
|
function rowToTask(row) {
|
|
94
102
|
if (row === undefined) return undefined
|
|
95
|
-
const { col, labels, waiting, labelColors, ...rest } = row
|
|
103
|
+
const { col, labels, waiting, labelColors, runs, ...rest } = row
|
|
96
104
|
let parsed = []
|
|
97
105
|
try {
|
|
98
106
|
const value = JSON.parse(labels)
|
|
@@ -103,7 +111,12 @@ function rowToTask(row) {
|
|
|
103
111
|
const value = JSON.parse(labelColors ?? '{}')
|
|
104
112
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) colors = value
|
|
105
113
|
} catch { /* испорченное поле не должно ронять чтение всей доски */ }
|
|
106
|
-
|
|
114
|
+
let parsedRuns = []
|
|
115
|
+
try {
|
|
116
|
+
const value = JSON.parse(runs ?? '[]')
|
|
117
|
+
if (Array.isArray(value)) parsedRuns = value
|
|
118
|
+
} catch { /* испорченное поле не должно ронять чтение всей доски */ }
|
|
119
|
+
return { ...rest, column: col, labels: parsed, labelColors: colors, waiting: waiting === 1, runs: parsedRuns }
|
|
107
120
|
}
|
|
108
121
|
|
|
109
122
|
/**
|
|
@@ -134,6 +147,13 @@ export function openStore({ dir }) {
|
|
|
134
147
|
['priority', "TEXT NOT NULL DEFAULT ''"],
|
|
135
148
|
['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
136
149
|
['workspaceId', "TEXT NOT NULL DEFAULT ''"],
|
|
150
|
+
['cron', "TEXT NOT NULL DEFAULT ''"],
|
|
151
|
+
['nextRunAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
152
|
+
['lastRunAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
153
|
+
['progressDump', "TEXT NOT NULL DEFAULT ''"],
|
|
154
|
+
['permission', "TEXT NOT NULL DEFAULT ''"],
|
|
155
|
+
['permissionConfirmedAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
156
|
+
['runs', "TEXT NOT NULL DEFAULT '[]'"],
|
|
137
157
|
]) {
|
|
138
158
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
139
159
|
}
|
|
@@ -165,6 +185,8 @@ export function openStore({ dir }) {
|
|
|
165
185
|
countInColumn: db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE board = ? AND col = ?'),
|
|
166
186
|
watchable: db.prepare(
|
|
167
187
|
"SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
|
|
188
|
+
scheduledDue: db.prepare(
|
|
189
|
+
"SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
|
|
168
190
|
}
|
|
169
191
|
|
|
170
192
|
function ensureBoard(id, title, kind) {
|
|
@@ -263,8 +285,9 @@ export function openStore({ dir }) {
|
|
|
263
285
|
db.prepare(`INSERT INTO tasks
|
|
264
286
|
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
265
287
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
266
|
-
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt
|
|
267
|
-
|
|
288
|
+
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
289
|
+
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs)
|
|
290
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
268
291
|
id, board, column, position,
|
|
269
292
|
input.title ?? '', input.body ?? '',
|
|
270
293
|
input.owner ?? null, input.repo ?? null,
|
|
@@ -281,6 +304,13 @@ export function openStore({ dir }) {
|
|
|
281
304
|
input.priority ?? '',
|
|
282
305
|
Number(input.dueAt) || 0,
|
|
283
306
|
now, born, now,
|
|
307
|
+
input.cron ?? '',
|
|
308
|
+
Number(input.nextRunAt) || 0,
|
|
309
|
+
Number(input.lastRunAt) || 0,
|
|
310
|
+
input.progressDump ?? '',
|
|
311
|
+
input.permission ?? '',
|
|
312
|
+
Number(input.permissionConfirmedAt) || 0,
|
|
313
|
+
typeof input.runs === 'string' ? input.runs : JSON.stringify(input.runs ?? []),
|
|
284
314
|
)
|
|
285
315
|
return rowToTask(q.taskById.get(id))
|
|
286
316
|
},
|
|
@@ -295,6 +325,7 @@ export function openStore({ dir }) {
|
|
|
295
325
|
fields.push(`${key} = ?`)
|
|
296
326
|
if (key === 'labels') values.push(JSON.stringify(patch[key] ?? []))
|
|
297
327
|
else if (key === 'labelColors') values.push(JSON.stringify(patch[key] ?? {}))
|
|
328
|
+
else if (key === 'runs') values.push(typeof patch[key] === 'string' ? patch[key] : JSON.stringify(patch[key] ?? []))
|
|
298
329
|
else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
|
|
299
330
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
300
331
|
else values.push(patch[key] ?? null)
|
|
@@ -306,6 +337,31 @@ export function openStore({ dir }) {
|
|
|
306
337
|
return rowToTask(q.taskById.get(id))
|
|
307
338
|
},
|
|
308
339
|
|
|
340
|
+
listDueTasks(now = Date.now()) {
|
|
341
|
+
return q.scheduledDue.all(now).map(rowToTask)
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
recordTaskRun(id, run) {
|
|
345
|
+
const task = rowToTask(q.taskById.get(id))
|
|
346
|
+
if (!task) return undefined
|
|
347
|
+
const runs = Array.isArray(task.runs) ? [...task.runs] : []
|
|
348
|
+
runs.unshift(run)
|
|
349
|
+
const trimmed = runs.slice(0, 20)
|
|
350
|
+
this.updateTask(id, { runs: JSON.stringify(trimmed) })
|
|
351
|
+
return rowToTask(q.taskById.get(id))
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
updateTaskRun(id, runId, patch) {
|
|
355
|
+
const task = rowToTask(q.taskById.get(id))
|
|
356
|
+
if (!task) return undefined
|
|
357
|
+
const runs = Array.isArray(task.runs) ? [...task.runs] : []
|
|
358
|
+
const idx = runs.findIndex((r) => r.id === runId)
|
|
359
|
+
if (idx === -1) return undefined
|
|
360
|
+
runs[idx] = { ...runs[idx], ...patch }
|
|
361
|
+
this.updateTask(id, { runs: JSON.stringify(runs) })
|
|
362
|
+
return rowToTask(q.taskById.get(id))
|
|
363
|
+
},
|
|
364
|
+
|
|
309
365
|
moveTask(id, { column, beforeId, afterId }) {
|
|
310
366
|
const current = q.taskById.get(id)
|
|
311
367
|
if (current === undefined) throw new Error(`задача ${id} не найдена`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|