@goodandready/dsh-kanban 0.1.24 → 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 +17 -0
- package/docs/design/DESIGN.md +2 -0
- package/lib/client.js +162 -2
- package/lib/config.js +16 -0
- package/lib/cron.js +154 -0
- package/lib/index.js +187 -6
- package/lib/launcher.js +116 -7
- 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 +65 -7
- package/lib/worktree.js +339 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -266,6 +266,23 @@ Task working directories (`resolveCwd`) respect operating system path convention
|
|
|
266
266
|
On Windows, repository root fencing accounts for drive letters and backslash path
|
|
267
267
|
separators, ensuring full test and runtime compatibility across POSIX and Windows.
|
|
268
268
|
|
|
269
|
+
## 🌳 Git Worktree Isolation
|
|
270
|
+
|
|
271
|
+
When in-progress work begins on a repository task, the agent session is automatically
|
|
272
|
+
isolated into a dedicated git worktree (`$DSH_HOME/worktrees/<repo-key>/<task-id>/` on branch
|
|
273
|
+
`task/<id>-<slug>`). The worktree is registered in DSH's `workspaceRegistry` for full UI
|
|
274
|
+
visibility and multi-agent concurrency protection. During the `cleanup` phase, uncommitted files
|
|
275
|
+
are detected via `git status --porcelain` to prevent data loss, followed by clean worktree and workspace
|
|
276
|
+
deregistration.
|
|
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
|
+
|
|
269
286
|
## 📄 License
|
|
270
287
|
|
|
271
288
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -50,3 +50,5 @@
|
|
|
50
50
|
- 2026-08-20 — Карточка настроек монтируется в settings.plugin.item с резервом в settings.section.
|
|
51
51
|
- 2026-08-25 — Использование дробной индексации ($O(1)$) для позиционирования карточек.
|
|
52
52
|
- 2026-09-02 — Снимок доски объединён на маршруте /dsh-kanban/snapshot (GET: выгрузка, POST: загрузка).
|
|
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
|
@@ -858,6 +858,30 @@ window.__ModuleLoader__.load({
|
|
|
858
858
|
'field.boardToolEnabled': 'Let the agent move cards',
|
|
859
859
|
'field.pushToGitea': 'Close the issue in Gitea',
|
|
860
860
|
'field.webhookSecretRef': 'Webhook credential name',
|
|
861
|
+
'field.worktreeIsolation': 'Isolate tasks in git worktrees',
|
|
862
|
+
'hint.worktreeIsolation': 'Creates a separate git worktree for each task session to prevent file collisions.',
|
|
863
|
+
'field.worktreeRoot': 'Worktree root directory',
|
|
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',
|
|
861
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.',
|
|
862
886
|
'hint.webhookSecretRef': 'NAME of a DSH credential holding the Gitea webhook secret. Empty leaves only polling.',
|
|
863
887
|
'hint.syncIntervalSec': 'Zero turns the check off. Below fifteen seconds is raised to fifteen.',
|
|
@@ -1069,6 +1093,9 @@ window.__ModuleLoader__.load({
|
|
|
1069
1093
|
'panel.startHint': 'A dedicated agent session opens for this task. Branch and worktree are created by the agent after its preflight, not here.',
|
|
1070
1094
|
'panel.workDir': 'Working folder: {path}',
|
|
1071
1095
|
'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
|
|
1096
|
+
'panel.worktree': 'Worktree: {path}',
|
|
1097
|
+
'panel.cleanupWorktree': 'Clean Worktree',
|
|
1098
|
+
'panel.worktreeDirty': 'Uncommitted changes in worktree: {files}',
|
|
1072
1099
|
'panel.priority': 'Priority',
|
|
1073
1100
|
'panel.priorityNone': 'No priority',
|
|
1074
1101
|
'priority.high': 'High',
|
|
@@ -1125,6 +1152,30 @@ window.__ModuleLoader__.load({
|
|
|
1125
1152
|
'field.boardToolEnabled': '允许 Agent 移动卡片',
|
|
1126
1153
|
'field.pushToGitea': '在 Gitea 中关闭 issue',
|
|
1127
1154
|
'field.webhookSecretRef': 'Webhook 凭据名称',
|
|
1155
|
+
'field.worktreeIsolation': '在 git worktree 中隔离任务',
|
|
1156
|
+
'hint.worktreeIsolation': '为每个任务会话创建独立的 git worktree 以防止文件冲突。',
|
|
1157
|
+
'field.worktreeRoot': 'Worktree 根目录',
|
|
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': '权限已确认',
|
|
1128
1179
|
'hint.pushToGitea': '卡片进入「已完成」即关闭其 issue。看板不设置任何标签:阶段从分支和 pull request 上已经看得出来。',
|
|
1129
1180
|
'hint.webhookSecretRef': '保存 Gitea webhook 密钥的 DSH 凭据的名称。留空则只剩轮询。',
|
|
1130
1181
|
'hint.syncIntervalSec': '填 0 关闭核对。小于十五秒会被提到十五秒。',
|
|
@@ -1336,6 +1387,9 @@ window.__ModuleLoader__.load({
|
|
|
1336
1387
|
'panel.startHint': '会为该任务打开一个专属的 Agent 会话。分支和 worktree 由 Agent 在自检之后创建,不在这里。',
|
|
1337
1388
|
'panel.workDir': '工作目录:{path}',
|
|
1338
1389
|
'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
|
|
1390
|
+
'panel.worktree': '工作区:{path}',
|
|
1391
|
+
'panel.cleanupWorktree': '清理工作区',
|
|
1392
|
+
'panel.worktreeDirty': '工作区中存在未提交的更改:{files}',
|
|
1339
1393
|
'panel.priority': '优先级',
|
|
1340
1394
|
'panel.priorityNone': '无优先级',
|
|
1341
1395
|
'priority.high': '高',
|
|
@@ -1395,6 +1449,14 @@ window.__ModuleLoader__.load({
|
|
|
1395
1449
|
{ field: 'boardToolEnabled', kind: 'bool', group: 'sync' },
|
|
1396
1450
|
{ field: 'pushToGitea', kind: 'bool', group: 'sync' },
|
|
1397
1451
|
{ field: 'webhookSecretRef', kind: 'text', group: 'sync' },
|
|
1452
|
+
{ field: 'worktreeIsolation', kind: 'bool', group: 'general' },
|
|
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' },
|
|
1398
1460
|
]
|
|
1399
1461
|
|
|
1400
1462
|
function hintKey(spec) {
|
|
@@ -1616,6 +1678,8 @@ window.__ModuleLoader__.load({
|
|
|
1616
1678
|
waiting ? React.createElement('span', { className: 'dkb-waitDot' }) : null,
|
|
1617
1679
|
waiting ? React.createElement('span', { className: 'dkb-waiting' }, t('board.waiting')) : null,
|
|
1618
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,
|
|
1619
1683
|
(task.labels || []).map((l) => React.createElement('span', {
|
|
1620
1684
|
className: 'dkb-tag', key: l, style: tagStyle(task, l),
|
|
1621
1685
|
}, l))),
|
|
@@ -1768,6 +1832,7 @@ window.__ModuleLoader__.load({
|
|
|
1768
1832
|
const [editPriority, setEditPriority] = React.useState('')
|
|
1769
1833
|
const [editLabels, setEditLabels] = React.useState('')
|
|
1770
1834
|
const [editDue, setEditDue] = React.useState('')
|
|
1835
|
+
const [editCron, setEditCron] = React.useState('')
|
|
1771
1836
|
const [overColumn, setOverColumn] = React.useState('')
|
|
1772
1837
|
const [model, setModel] = React.useState('')
|
|
1773
1838
|
const [provider, setProvider] = React.useState('')
|
|
@@ -1981,6 +2046,7 @@ window.__ModuleLoader__.load({
|
|
|
1981
2046
|
setEditDue(typeof task?.dueAt === 'number' && task.dueAt > 0
|
|
1982
2047
|
? new Date(task.dueAt).toISOString().slice(0, 10)
|
|
1983
2048
|
: '')
|
|
2049
|
+
setEditCron(typeof task?.cron === 'string' ? task.cron : '')
|
|
1984
2050
|
api('/task/' + encodeURIComponent(task.id) + '/body')
|
|
1985
2051
|
.then((out) => setBody((out && out.blocks) || []))
|
|
1986
2052
|
.catch(() => setBody([]))
|
|
@@ -2507,6 +2573,30 @@ window.__ModuleLoader__.load({
|
|
|
2507
2573
|
target: '_blank', rel: 'noreferrer noopener',
|
|
2508
2574
|
}, ' · ' + t('panel.issue') + ' ↗')
|
|
2509
2575
|
: null),
|
|
2576
|
+
openTask.worktree
|
|
2577
|
+
? React.createElement('div', { className: 'dkb-note', style: { wordBreak: 'break-all' } },
|
|
2578
|
+
t('panel.worktree', { path: openTask.worktree })
|
|
2579
|
+
+ (openTask.branch ? ' (' + openTask.branch + ')' : ''))
|
|
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,
|
|
2510
2600
|
// Пустое тело — отсутствие раздела, а не пустой раздел с заголовком.
|
|
2511
2601
|
body === null
|
|
2512
2602
|
? React.createElement('p', { className: 'dkb-note' }, t('panel.bodyLoading'))
|
|
@@ -2541,6 +2631,18 @@ window.__ModuleLoader__.load({
|
|
|
2541
2631
|
onChange: (e) => setEditDue(e.target.value),
|
|
2542
2632
|
}),
|
|
2543
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
|
+
),
|
|
2544
2646
|
React.createElement('button', {
|
|
2545
2647
|
type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
|
|
2546
2648
|
onClick: async () => {
|
|
@@ -2553,7 +2655,7 @@ window.__ModuleLoader__.load({
|
|
|
2553
2655
|
: new Date(editDue + 'T00:00:00').getTime()
|
|
2554
2656
|
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/update', {
|
|
2555
2657
|
method: 'POST',
|
|
2556
|
-
body: JSON.stringify({ priority: editPriority, labels, dueAt }),
|
|
2658
|
+
body: JSON.stringify({ priority: editPriority, labels, dueAt, cron: editCron }),
|
|
2557
2659
|
})
|
|
2558
2660
|
setOpenTask(out.task)
|
|
2559
2661
|
await load(board)
|
|
@@ -2561,6 +2663,47 @@ window.__ModuleLoader__.load({
|
|
|
2561
2663
|
},
|
|
2562
2664
|
}, t('panel.saveEdit')),
|
|
2563
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,
|
|
2564
2707
|
// Взять и отдать — одна кнопка в двух состояниях: держать рядом
|
|
2565
2708
|
// «взять» и «отказаться» значит предлагать бессмысленное действие.
|
|
2566
2709
|
React.createElement('button', {
|
|
@@ -2717,7 +2860,24 @@ window.__ModuleLoader__.load({
|
|
|
2717
2860
|
},
|
|
2718
2861
|
}, t('panel.archive'))
|
|
2719
2862
|
: null,
|
|
2720
|
-
|
|
2863
|
+
openTask.column === 'cleanup' && openTask.worktree
|
|
2864
|
+
? React.createElement('button', {
|
|
2865
|
+
type: 'button', className: 'dkb-discard',
|
|
2866
|
+
onClick: async () => {
|
|
2867
|
+
try {
|
|
2868
|
+
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/cleanup-worktree', {
|
|
2869
|
+
method: 'POST', body: '{}',
|
|
2870
|
+
})
|
|
2871
|
+
if (out && out.error === 'worktree-dirty') {
|
|
2872
|
+
alert(t('panel.worktreeDirty', { files: (out.files || []).join(', ') }))
|
|
2873
|
+
} else {
|
|
2874
|
+
setOpenTask(null)
|
|
2875
|
+
await load(board)
|
|
2876
|
+
}
|
|
2877
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2878
|
+
},
|
|
2879
|
+
}, t('panel.cleanupWorktree'))
|
|
2880
|
+
: null,
|
|
2721
2881
|
),
|
|
2722
2882
|
React.createElement('button', {
|
|
2723
2883
|
type: 'button', className: 'dkb-save', disabled: starting || model === '',
|
package/lib/config.js
CHANGED
|
@@ -71,6 +71,14 @@ export const CONFIG_DEFAULTS = {
|
|
|
71
71
|
boardToolEnabled: false,
|
|
72
72
|
webhookSecretRef: '',
|
|
73
73
|
pushToGitea: true,
|
|
74
|
+
worktreeIsolation: true,
|
|
75
|
+
worktreeRoot: '',
|
|
76
|
+
cronEnabled: false,
|
|
77
|
+
preventIdleSleep: false,
|
|
78
|
+
progressDumpEnabled: false,
|
|
79
|
+
permissionGateEnabled: false,
|
|
80
|
+
sessionDefaultPermission: 'read-only',
|
|
81
|
+
announceToAgent: true,
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/** Пояснения к полям; повторно используются схемой и карточкой настроек. */
|
|
@@ -91,6 +99,14 @@ export const CONFIG_HINTS = {
|
|
|
91
99
|
boardToolEnabled: 'Разрешить агенту двигать карточки инструментом board_move. Выключено, пока скилл воркфлоу запрещает CLI трогать канбан.',
|
|
92
100
|
webhookSecretRef: 'ИМЯ учётной записи DSH с секретом вебхука Gitea. Пусто — вебхук не принимается, остаётся только опрос.',
|
|
93
101
|
pushToGitea: 'Закрывать issue в Gitea, когда карточка уходит в колонку «Выполнено».',
|
|
102
|
+
worktreeIsolation: 'Изолировать сессии задач в отдельные git worktrees для предотвращения коллизий.',
|
|
103
|
+
worktreeRoot: 'Каталог для воркдеревьев задач. Пусто — $DSH_HOME/worktrees или .worktrees проекта.',
|
|
104
|
+
cronEnabled: 'Включить фоновый запуск задач по расписанию cron на стороне хоста.',
|
|
105
|
+
preventIdleSleep: 'Запретить переход хоста в спящий режим, пока есть активные сессии задач.',
|
|
106
|
+
progressDumpEnabled: 'Включить поддержку фиксации срезов PROGRESSDUMP для передачи эстафеты между сессиями.',
|
|
107
|
+
permissionGateEnabled: 'Требовать подтверждения человеком для запуска задач с повышенными правами.',
|
|
108
|
+
sessionDefaultPermission: 'Базовый уровень прав без подтверждения. Права выше этого требуют одобрения.',
|
|
109
|
+
announceToAgent: 'Добавлять секцию о доске задач в системный промпт агентов.',
|
|
94
110
|
}
|
|
95
111
|
|
|
96
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
|
+
}
|