@goodandready/dsh-cron 0.3.19 → 0.3.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/README.md +4 -3
- package/lib/api-collection.js +2 -1
- package/lib/client.css +4 -4
- package/lib/client.js +45 -8
- package/lib/cron-tool.js +5 -1
- package/lib/locales.json +1 -0
- package/lib/runner.js +26 -5
- package/lib/scheduler-completion.js +8 -4
- package/lib/store.js +69 -3
- package/lib/task-create.js +2 -1
- package/lib/task-patch.js +10 -0
- package/lib/task-transfer.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,8 +96,9 @@ graph TD
|
|
|
96
96
|
- **Automated Synthesis**: Aggregates worker outputs into a structured Markdown digest or delegates synthesis to a designated synthesizer model.
|
|
97
97
|
- **Swarm Metrics & History**: Run history records total, succeeded, failed counts, and duration alongside detailed per-worker execution logs.
|
|
98
98
|
|
|
99
|
-
### 🔍 Git Diff & Interactive Patch Review (#351)
|
|
100
|
-
- **
|
|
99
|
+
### 🔍 Git Diff & Interactive Patch Review (#351, #379)
|
|
100
|
+
- **Scoped Diff Capture**: Tasks running in Git worktrees capture modified files, stat summary, and unified diffs (up to 256 KiB) automatically. Non-worktree tasks ignore repository diffs by default, avoiding storage bloat, but can opt in explicitly via `captureGitDiff: true`.
|
|
101
|
+
- **Lightweight Archive Storage**: Historical run archives automatically strip heavy raw diff payloads into concise excerpts with byte-budget caps (max 15MB) and automatic cleanup of orphaned `.tmp.*` files.
|
|
101
102
|
- **1-Click Discard**: Revert uncommitted working directory changes safely with a single click (`git reset --hard HEAD && git clean -fd`).
|
|
102
103
|
- **1-Click Apply**: Re-apply previously captured diffs directly to the repository (`git apply --whitespace=fix`).
|
|
103
104
|
- **Interactive Run Preview**: Color-coded diff inspector embedded directly into the execution history drawer.
|
|
@@ -215,7 +216,7 @@ Every task picks its own runtime; non-LLM runtimes need no model and consume no
|
|
|
215
216
|
|
|
216
217
|
### 8. Session Integration & Permissions
|
|
217
218
|
* **Real Assistant Output & Terminal Status Extraction** — Isolates current turn session events, extracts the actual assistant message text (excluding previous turn history in persistent sessions), and validates terminal turn status (`turn/end` errors or interruptions) to guarantee accurate reporting, chaining, and structured action execution.
|
|
218
|
-
* **Per-task permission presets** — `default`, `read-only`, `workspace-write`, or `full` are applied to the task's agent session before the prompt runs.
|
|
219
|
+
* **Per-task permission presets & unattended approval** — `default`, `read-only`, `workspace-write`, or `danger-full-access` (aliased as `full`) are mapped directly to host `SandboxMode` and applied to the task's agent session before the prompt runs. For unattended background runs, approval policy is pinned to `never` via host `approval` service to eliminate interactive approval stalls.
|
|
219
220
|
* **Session auto-archive** — isolated cron sessions are archived after each run (best-effort) so they do not clutter the chat list.
|
|
220
221
|
* **History → session navigation** — every LLM run records its session; open it straight from the run history entry.
|
|
221
222
|
|
package/lib/api-collection.js
CHANGED
|
@@ -169,7 +169,7 @@ export function buildTaskRecord(store, body, taskType, parsed) {
|
|
|
169
169
|
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
|
|
170
170
|
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
171
171
|
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
172
|
-
permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
|
|
172
|
+
permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
|
|
173
173
|
validators: Array.isArray(body.validators) ? sanitizeValidators(body.validators) : (current ? current.validators : undefined),
|
|
174
174
|
...mergeExecutionFields(current, body, parsed),
|
|
175
175
|
};
|
|
@@ -194,6 +194,7 @@ export function mergeExecutionFields(current, body, parsed) {
|
|
|
194
194
|
workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
|
|
195
195
|
worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
|
|
196
196
|
keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
|
|
197
|
+
captureGitDiff: body.captureGitDiff !== undefined ? Boolean(body.captureGitDiff) : (body.captureDiff !== undefined ? Boolean(body.captureDiff) : (current ? current.captureGitDiff : undefined)),
|
|
197
198
|
httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
|
|
198
199
|
httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
|
|
199
200
|
httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
|
package/lib/client.css
CHANGED
|
@@ -13,18 +13,18 @@
|
|
|
13
13
|
[data-pane=conversation], [class*=centerCol] {
|
|
14
14
|
position: relative !important;
|
|
15
15
|
}
|
|
16
|
-
[
|
|
16
|
+
[data-dsh-cron-view] {
|
|
17
17
|
z-index: 60;
|
|
18
18
|
background: var(--dsw-alias-bg-base);
|
|
19
19
|
display: none;
|
|
20
20
|
position: absolute;
|
|
21
21
|
inset: 0;
|
|
22
22
|
}
|
|
23
|
-
html[
|
|
23
|
+
html[data-dsh-cron-active] [data-dsh-cron-view] {
|
|
24
24
|
display: block !important;
|
|
25
25
|
}
|
|
26
|
-
html[
|
|
27
|
-
html[
|
|
26
|
+
html[data-dsh-cron-active] [data-pane=conversation] > *:not([data-dsh-cron-view]),
|
|
27
|
+
html[data-dsh-cron-active] [class*=centerCol] > *:not([data-dsh-cron-view]) {
|
|
28
28
|
display: none !important;
|
|
29
29
|
}
|
|
30
30
|
|
package/lib/client.js
CHANGED
|
@@ -470,8 +470,12 @@ const listeners = new Set();
|
|
|
470
470
|
return {
|
|
471
471
|
isOpen: () => open,
|
|
472
472
|
set: (v) => {
|
|
473
|
-
|
|
474
|
-
open
|
|
473
|
+
const next = !!v;
|
|
474
|
+
if (open === next) {
|
|
475
|
+
for (const fn of listeners) fn(open);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
open = next;
|
|
475
479
|
for (const fn of listeners) fn(open);
|
|
476
480
|
},
|
|
477
481
|
toggle: () => {
|
|
@@ -1328,6 +1332,12 @@ style: { padding: '6px 16px', fontSize: '12px' }
|
|
|
1328
1332
|
function CronScreen(props) {
|
|
1329
1333
|
const { ctx, toggle, onClose } = props;
|
|
1330
1334
|
const t = props.t || translate;
|
|
1335
|
+
const [isOpen, setIsOpen] = React.useState(toggle && typeof toggle.isOpen === 'function' ? toggle.isOpen() : true);
|
|
1336
|
+
React.useEffect(() => {
|
|
1337
|
+
if (!toggle || typeof toggle.subscribe !== 'function') return;
|
|
1338
|
+
return toggle.subscribe((val) => setIsOpen(val));
|
|
1339
|
+
}, [toggle]);
|
|
1340
|
+
if (!isOpen) return null;
|
|
1331
1341
|
const [tab, setTab] = React.useState('all');
|
|
1332
1342
|
const [query, setQuery] = React.useState('');
|
|
1333
1343
|
const [filterType, setFilterType] = React.useState('');
|
|
@@ -1431,6 +1441,7 @@ const [formCwd, setFormCwd] = React.useState('');
|
|
|
1431
1441
|
const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
|
|
1432
1442
|
const [formWorktree, setFormWorktree] = React.useState(false);
|
|
1433
1443
|
const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
|
|
1444
|
+
const [formCaptureGitDiff, setFormCaptureGitDiff] = React.useState(false);
|
|
1434
1445
|
const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
|
|
1435
1446
|
const [formHttpUrl, setFormHttpUrl] = React.useState('');
|
|
1436
1447
|
const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
|
|
@@ -1486,6 +1497,7 @@ cwd: String(formCwd || '').trim(),
|
|
|
1486
1497
|
workspaceId: String(formWorkspaceId || '').trim(),
|
|
1487
1498
|
worktree: Boolean(formWorktree),
|
|
1488
1499
|
keepWorktree: Boolean(formKeepWorktree),
|
|
1500
|
+
captureGitDiff: Boolean(formCaptureGitDiff),
|
|
1489
1501
|
httpMethod: String(formHttpMethod || 'GET'),
|
|
1490
1502
|
httpUrl: String(formHttpUrl || '').trim(),
|
|
1491
1503
|
httpHttpHeaders: String(formHttpHeaders || '').trim(),
|
|
@@ -2120,6 +2132,7 @@ setFormCwd('');
|
|
|
2120
2132
|
setFormWorkspaceId('');
|
|
2121
2133
|
setFormWorktree(false);
|
|
2122
2134
|
setFormKeepWorktree(false);
|
|
2135
|
+
setFormCaptureGitDiff(false);
|
|
2123
2136
|
setFormHttpMethod('GET');
|
|
2124
2137
|
setFormHttpUrl('');
|
|
2125
2138
|
setFormHttpHeaders('');
|
|
@@ -2207,6 +2220,7 @@ cwd: '',
|
|
|
2207
2220
|
workspaceId: '',
|
|
2208
2221
|
worktree: false,
|
|
2209
2222
|
keepWorktree: false,
|
|
2223
|
+
captureGitDiff: false,
|
|
2210
2224
|
httpMethod: 'GET',
|
|
2211
2225
|
httpUrl: '',
|
|
2212
2226
|
httpHttpHeaders: '',
|
|
@@ -2332,7 +2346,7 @@ setSchedulePreviewRuns(null);
|
|
|
2332
2346
|
setFormTimezone(task.timezone || '');
|
|
2333
2347
|
setFormMisfirePolicy(task.misfirePolicy || 'skip');
|
|
2334
2348
|
setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
|
|
2335
|
-
setFormPermissionPreset(task.permissionPreset || 'default');
|
|
2349
|
+
setFormPermissionPreset(task.permissionPreset === 'full' ? 'danger-full-access' : (task.permissionPreset || 'default'));
|
|
2336
2350
|
setFormAgentPreset(task.agentPreset || '');
|
|
2337
2351
|
setFormSessionTitle(task.sessionTitle || '');
|
|
2338
2352
|
setFormTargetSessionId(task.targetSessionId || '');
|
|
@@ -2342,6 +2356,7 @@ setFormCwd(task.cwd || '');
|
|
|
2342
2356
|
setFormWorkspaceId(task.workspaceId || '');
|
|
2343
2357
|
setFormWorktree(Boolean(task.worktree));
|
|
2344
2358
|
setFormKeepWorktree(Boolean(task.keepWorktree));
|
|
2359
|
+
setFormCaptureGitDiff(task.captureGitDiff !== undefined ? Boolean(task.captureGitDiff) : Boolean(task.worktree));
|
|
2345
2360
|
setFormHttpMethod(task.httpMethod || 'GET');
|
|
2346
2361
|
setFormHttpUrl(task.httpUrl || '');
|
|
2347
2362
|
setFormHttpHeaders(typeof task.httpHeaders === 'string' ? task.httpHeaders : (task.httpHeaders ? JSON.stringify(task.httpHeaders) : ''));
|
|
@@ -2416,6 +2431,7 @@ cwd: formCwd.trim(),
|
|
|
2416
2431
|
workspaceId: formWorkspaceId.trim(),
|
|
2417
2432
|
worktree: isAgentType ? Boolean(formWorktree) : false,
|
|
2418
2433
|
keepWorktree: isAgentType ? Boolean(formKeepWorktree) : false,
|
|
2434
|
+
captureGitDiff: Boolean(formCaptureGitDiff),
|
|
2419
2435
|
httpMethod: formHttpMethod,
|
|
2420
2436
|
httpUrl: formHttpUrl.trim(),
|
|
2421
2437
|
httpHeaders: formHttpHeaders.trim() || undefined,
|
|
@@ -3413,7 +3429,7 @@ onChange: (e) => setFormPermissionPreset(e.target.value)
|
|
|
3413
3429
|
_h('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
|
|
3414
3430
|
_h('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
|
|
3415
3431
|
_h('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
|
|
3416
|
-
_h('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
|
|
3432
|
+
_h('option', { value: 'danger-full-access' }, T_KEY(t, 'form.permissionFull'))
|
|
3417
3433
|
)
|
|
3418
3434
|
)
|
|
3419
3435
|
)
|
|
@@ -3673,6 +3689,14 @@ checked: formKeepWorktree,
|
|
|
3673
3689
|
onChange: (e) => setFormKeepWorktree(e.target.checked)
|
|
3674
3690
|
}),
|
|
3675
3691
|
_h('span', null, T_KEY(t, 'form.keepWorktreeLabel'))
|
|
3692
|
+
),
|
|
3693
|
+
_h('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: formWorktree ? '24px' : '0px', marginTop: '6px' } },
|
|
3694
|
+
_h('input', {
|
|
3695
|
+
type: 'checkbox',
|
|
3696
|
+
checked: formCaptureGitDiff,
|
|
3697
|
+
onChange: (e) => setFormCaptureGitDiff(e.target.checked)
|
|
3698
|
+
}),
|
|
3699
|
+
_h('span', null, T_KEY(t, 'form.captureGitDiffLabel'))
|
|
3676
3700
|
)
|
|
3677
3701
|
),
|
|
3678
3702
|
formType !== 'http' && _h('div', { className: 'dsh-cron-form-group' },
|
|
@@ -4914,7 +4938,21 @@ function mountCronScreen(ctx, toggle) {
|
|
|
4914
4938
|
if (typeof document === 'undefined') return () => {};
|
|
4915
4939
|
let root;
|
|
4916
4940
|
let container;
|
|
4941
|
+
const teardownPanel = () => {
|
|
4942
|
+
if (root !== undefined) {
|
|
4943
|
+
try { root.unmount(); } catch (e) { /* ignore */ }
|
|
4944
|
+
root = undefined;
|
|
4945
|
+
}
|
|
4946
|
+
if (container !== undefined) {
|
|
4947
|
+
try { container.remove(); } catch (e) { /* ignore */ }
|
|
4948
|
+
container = undefined;
|
|
4949
|
+
}
|
|
4950
|
+
};
|
|
4917
4951
|
const ensure = () => {
|
|
4952
|
+
if (!toggle.isOpen()) {
|
|
4953
|
+
teardownPanel();
|
|
4954
|
+
return;
|
|
4955
|
+
}
|
|
4918
4956
|
if (container !== undefined && container.isConnected) return;
|
|
4919
4957
|
const column = document.querySelector(COLUMN_SELECTOR);
|
|
4920
4958
|
if (column === null) return;
|
|
@@ -4934,8 +4972,10 @@ if (toggle.isOpen()) {
|
|
|
4934
4972
|
ensureStyles();
|
|
4935
4973
|
document.documentElement.setAttribute(ACTIVE_ATTR, '');
|
|
4936
4974
|
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
|
|
4975
|
+
ensure();
|
|
4937
4976
|
} else {
|
|
4938
4977
|
document.documentElement.removeAttribute(ACTIVE_ATTR);
|
|
4978
|
+
teardownPanel();
|
|
4939
4979
|
}
|
|
4940
4980
|
};
|
|
4941
4981
|
const onOtherActivate = (event) => {
|
|
@@ -4959,10 +4999,7 @@ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
|
|
|
4959
4999
|
document.removeEventListener('click', onSidebarClick, true);
|
|
4960
5000
|
unsubscribe();
|
|
4961
5001
|
document.documentElement.removeAttribute(ACTIVE_ATTR);
|
|
4962
|
-
|
|
4963
|
-
root = undefined;
|
|
4964
|
-
if (container !== undefined) container.remove();
|
|
4965
|
-
container = undefined;
|
|
5002
|
+
teardownPanel();
|
|
4966
5003
|
};
|
|
4967
5004
|
}
|
|
4968
5005
|
function registerIntoMount(ctx, mountName, entry, component) {
|
package/lib/cron-tool.js
CHANGED
|
@@ -149,9 +149,13 @@ export const cronToolParameters = {
|
|
|
149
149
|
},
|
|
150
150
|
permissionPreset: {
|
|
151
151
|
type: 'string',
|
|
152
|
-
enum: ['default', 'read-only', 'workspace-write', 'full'],
|
|
152
|
+
enum: ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'],
|
|
153
153
|
description: 'Permission preset applied to the task session (only used for agent session tasks: llm, skill, workflow)',
|
|
154
154
|
},
|
|
155
|
+
captureGitDiff: {
|
|
156
|
+
type: 'boolean',
|
|
157
|
+
description: 'Whether to capture git diff on run completion. Defaults to true for worktree tasks and false for non-worktree tasks.',
|
|
158
|
+
},
|
|
155
159
|
sessionTitle: {
|
|
156
160
|
type: 'string',
|
|
157
161
|
description: 'Custom session title template for isolated agent runs (supports {{date}} placeholder, e.g. "{{date}} [Daily] Report")',
|
package/lib/locales.json
CHANGED
|
@@ -179,6 +179,7 @@
|
|
|
179
179
|
"form.permissionDefault": "默认 (继承主配置)",
|
|
180
180
|
"form.permissionRestricted": "受限模式 (只读)",
|
|
181
181
|
"form.permissionFull": "完全访问权限",
|
|
182
|
+
"form.captureGitDiffLabel": "捕获 Git Diff (任务结束时记录代码变更)",
|
|
182
183
|
"form.envLabel": "环境变量 (KEY=VALUE, 每行一个)",
|
|
183
184
|
"form.envPlaceholder": "FOO=bar\\nAPI_KEY=secret",
|
|
184
185
|
"form.cwdLabel": "工作目录 (绝对路径)",
|
package/lib/runner.js
CHANGED
|
@@ -644,16 +644,37 @@ export class SessionRunner {
|
|
|
644
644
|
}
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
-
/** Per-job permission preset (#32): read-only | workspace-write | full. */
|
|
647
|
+
/** Per-job permission preset (#32, #380, GH #18): read-only | workspace-write | danger-full-access (or full alias). */
|
|
648
648
|
_applyPermissionPreset(task, handle) {
|
|
649
|
-
if (!task.permissionPreset || task.permissionPreset === 'default')
|
|
649
|
+
if (!task.permissionPreset || task.permissionPreset === 'default') {
|
|
650
|
+
this._pinApprovalPolicy(handle);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const targetPreset = task.permissionPreset === 'full' ? 'danger-full-access' : task.permissionPreset;
|
|
650
654
|
try {
|
|
651
|
-
const presets = this.ctx.get
|
|
655
|
+
const presets = this.ctx.get ? this.ctx.get('permissionPresets') : this.ctx.permissionPresets;
|
|
652
656
|
if (presets && typeof presets.set === 'function') {
|
|
653
|
-
presets.set(handle.agent.session,
|
|
657
|
+
presets.set(handle.agent.session, targetPreset);
|
|
654
658
|
}
|
|
655
659
|
} catch (ppErr) {
|
|
656
|
-
logger.warn(
|
|
660
|
+
logger.warn(`[dsh-cron] permission preset "${task.permissionPreset}" (target: "${targetPreset}") not applied to task ${task.id || ''}:`, ppErr.message);
|
|
661
|
+
}
|
|
662
|
+
this._pinApprovalPolicy(handle);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
_pinApprovalPolicy(handle) {
|
|
666
|
+
try {
|
|
667
|
+
const approval = this.ctx.get ? this.ctx.get('approval') : this.ctx.approval;
|
|
668
|
+
if (approval && typeof approval.setPolicy === 'function') {
|
|
669
|
+
if (handle?.agent) {
|
|
670
|
+
bestEffort('approval-set-policy-agent', () => approval.setPolicy(handle.agent, 'never'), logger);
|
|
671
|
+
}
|
|
672
|
+
if (handle?.agent?.session) {
|
|
673
|
+
bestEffort('approval-set-policy-session', () => approval.setPolicy(handle.agent.session, 'never'), logger);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
} catch (appErr) {
|
|
677
|
+
logger.warn('[dsh-cron] approval setPolicy not applied:', appErr.message);
|
|
657
678
|
}
|
|
658
679
|
}
|
|
659
680
|
|
|
@@ -142,16 +142,20 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
142
142
|
runInfo.swarmStats = outcome.swarmStats;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
// Capture git diff if task runs in a worktree or
|
|
146
|
-
const
|
|
145
|
+
// Capture git diff if task runs in a worktree or explicitly opted in (#351, #379, GH #17)
|
|
146
|
+
const explicitCapture = (typeof task.captureGitDiff === 'boolean')
|
|
147
|
+
? task.captureGitDiff
|
|
148
|
+
: ((typeof task.captureDiff === 'boolean') ? task.captureDiff : null);
|
|
149
|
+
const shouldCaptureDiff = explicitCapture !== null ? explicitCapture : Boolean(task.worktree);
|
|
150
|
+
const diffDir = shouldCaptureDiff ? (task.worktree ? (runInfo.worktreePath || task.cwd) : task.cwd) : null;
|
|
147
151
|
if (diffDir) {
|
|
148
152
|
try {
|
|
149
153
|
const diffResult = await captureGitDiff(diffDir);
|
|
150
154
|
if (diffResult && diffResult.hasChanges) {
|
|
151
155
|
runInfo.gitDiff = diffResult;
|
|
152
156
|
}
|
|
153
|
-
} catch {
|
|
154
|
-
|
|
157
|
+
} catch (diffErr) {
|
|
158
|
+
bestEffort('log-diff-error', () => scheduler?.logger?.debug?.(`[dsh-cron] git diff capture failed: ${diffErr?.message}`));
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
|
package/lib/store.js
CHANGED
|
@@ -60,6 +60,7 @@ export class TaskStore {
|
|
|
60
60
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
61
|
}
|
|
62
62
|
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
63
|
+
this._cleanOrphanedTmpFiles(dir);
|
|
63
64
|
const bakPath = `${this.filePath}.bak`;
|
|
64
65
|
|
|
65
66
|
const loadData = (raw) => {
|
|
@@ -823,6 +824,17 @@ export class TaskStore {
|
|
|
823
824
|
},
|
|
824
825
|
...prevRuns,
|
|
825
826
|
];
|
|
827
|
+
// Truncate raw diff in older active history entries to bound memory and JSON size (#379, GH #17)
|
|
828
|
+
for (let i = 10; i < runs.length; i++) {
|
|
829
|
+
const r = runs[i];
|
|
830
|
+
if (r && r.gitDiff && r.gitDiff.diff && r.gitDiff.diff.length > 2000) {
|
|
831
|
+
r.gitDiff = {
|
|
832
|
+
...r.gitDiff,
|
|
833
|
+
diff: r.gitDiff.diff.slice(0, 2000) + '\n... [diff truncated in history]',
|
|
834
|
+
truncated: true,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
826
838
|
// Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
|
|
827
839
|
if (runs.length > 100) {
|
|
828
840
|
const overflow = runs.splice(100);
|
|
@@ -833,7 +845,32 @@ export class TaskStore {
|
|
|
833
845
|
}
|
|
834
846
|
|
|
835
847
|
/**
|
|
836
|
-
*
|
|
848
|
+
* Remove orphaned temporary write files older than 10 minutes (#379, GH #17).
|
|
849
|
+
*/
|
|
850
|
+
_cleanOrphanedTmpFiles(dir) {
|
|
851
|
+
try {
|
|
852
|
+
if (!fs.existsSync(dir)) return;
|
|
853
|
+
const entries = fs.readdirSync(dir);
|
|
854
|
+
const now = Date.now();
|
|
855
|
+
const tenMinutesMs = 10 * 60 * 1000;
|
|
856
|
+
for (const entry of entries) {
|
|
857
|
+
if (entry.includes('.tmp.')) {
|
|
858
|
+
const fullPath = path.join(dir, entry);
|
|
859
|
+
bestEffort('clean-tmp-file', () => {
|
|
860
|
+
const stat = fs.statSync(fullPath);
|
|
861
|
+
if (stat.isFile() && (now - stat.mtimeMs > tenMinutesMs)) {
|
|
862
|
+
fs.unlinkSync(fullPath);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
} catch (dirErr) {
|
|
868
|
+
bestEffort('log-clean-tmp-dir-error', () => logger.debug?.(`[dsh-cron] clean orphaned tmp dir error: ${dirErr?.message}`));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Append rotated overflow history entries into tasks-history-archive.json (#134, #379, GH #17).
|
|
837
874
|
*/
|
|
838
875
|
_archiveHistoryRuns(id, overflowRuns) {
|
|
839
876
|
if (!Array.isArray(overflowRuns) || overflowRuns.length === 0) return;
|
|
@@ -845,14 +882,43 @@ export class TaskStore {
|
|
|
845
882
|
archive = bestEffort('parse-history-archive', () => JSON.parse(fs.readFileSync(archivePath, 'utf-8'))) || {};
|
|
846
883
|
}
|
|
847
884
|
if (!Array.isArray(archive[id])) archive[id] = [];
|
|
848
|
-
|
|
885
|
+
|
|
886
|
+
// Strip full raw diff payload from archived overflow runs to prevent runaway JSON growth (#379, GH #17)
|
|
887
|
+
const sanitizedOverflow = overflowRuns.map((r) => {
|
|
888
|
+
if (!r || !r.gitDiff) return r;
|
|
889
|
+
const { diff, ...gitDiffMeta } = r.gitDiff;
|
|
890
|
+
return {
|
|
891
|
+
...r,
|
|
892
|
+
gitDiff: {
|
|
893
|
+
...gitDiffMeta,
|
|
894
|
+
diffExcerpt: diff ? diff.slice(0, 500) : '',
|
|
895
|
+
hasChanges: Boolean(gitDiffMeta.hasChanges),
|
|
896
|
+
archived: true,
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
archive[id].push(...sanitizedOverflow);
|
|
849
902
|
if (archive[id].length > 1000) {
|
|
850
903
|
archive[id] = archive[id].slice(-1000);
|
|
851
904
|
}
|
|
905
|
+
|
|
906
|
+
// Enforce total archive size budget (max 15MB) across tasks (#379, GH #17)
|
|
907
|
+
const MAX_ARCHIVE_BYTES = 15 * 1024 * 1024;
|
|
908
|
+
let serialized = JSON.stringify(archive);
|
|
909
|
+
if (serialized.length > MAX_ARCHIVE_BYTES) {
|
|
910
|
+
for (const taskId of Object.keys(archive)) {
|
|
911
|
+
if (Array.isArray(archive[taskId]) && archive[taskId].length > 100) {
|
|
912
|
+
archive[taskId] = archive[taskId].slice(-100);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
serialized = JSON.stringify(archive);
|
|
916
|
+
}
|
|
917
|
+
|
|
852
918
|
// Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
|
|
853
919
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
854
920
|
const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
855
|
-
fs.writeFileSync(tmp,
|
|
921
|
+
fs.writeFileSync(tmp, serialized, { encoding: 'utf-8', mode: 0o600 });
|
|
856
922
|
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
857
923
|
fs.renameSync(tmp, archivePath);
|
|
858
924
|
bestEffort('chmod-archive', () => fs.chmodSync(archivePath, 0o600));
|
package/lib/task-create.js
CHANGED
|
@@ -85,7 +85,8 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
85
85
|
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
|
|
86
86
|
maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
|
|
87
87
|
retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
|
|
88
|
-
permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
|
|
88
|
+
permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
|
|
89
|
+
captureGitDiff: typeof args.captureGitDiff === 'boolean' ? args.captureGitDiff : (typeof args.captureDiff === 'boolean' ? args.captureDiff : undefined),
|
|
89
90
|
kanbanMode: args.kanbanMode || 'none',
|
|
90
91
|
channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
|
|
91
92
|
template: args.template ? String(args.template) : '',
|
package/lib/task-patch.js
CHANGED
|
@@ -156,6 +156,16 @@ export function buildTaskPatch(current, body) {
|
|
|
156
156
|
if (patch.tokenLimit !== undefined) {
|
|
157
157
|
patch.tokenLimit = patch.tokenLimit === null ? null : (Number(patch.tokenLimit) > 0 ? Number(patch.tokenLimit) : null);
|
|
158
158
|
}
|
|
159
|
+
if (patch.permissionPreset !== undefined) {
|
|
160
|
+
patch.permissionPreset = ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(patch.permissionPreset)
|
|
161
|
+
? patch.permissionPreset
|
|
162
|
+
: 'default';
|
|
163
|
+
}
|
|
164
|
+
if (patch.captureGitDiff !== undefined) {
|
|
165
|
+
patch.captureGitDiff = Boolean(patch.captureGitDiff);
|
|
166
|
+
} else if (patch.captureDiff !== undefined) {
|
|
167
|
+
patch.captureGitDiff = Boolean(patch.captureDiff);
|
|
168
|
+
}
|
|
159
169
|
const effectiveType = patch.type !== undefined ? normalizeTaskType(patch.type) : current.type;
|
|
160
170
|
if (EXTERNAL_RUNTIMES.includes(effectiveType) && patch.permissionPreset !== undefined) {
|
|
161
171
|
patch.permissionPreset = 'default';
|
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|