@goodandready/dsh-cron 0.2.52 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -407,6 +407,30 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
407
407
  sendJson(res, 200, { ok: true, proposal: result.proposal });
408
408
  return true;
409
409
  }
410
+ if (action === 'resolve-uncertain') {
411
+ const task = store.get(id);
412
+ if (!task) {
413
+ sendJson(res, 404, NOT_FOUND);
414
+ return true;
415
+ }
416
+ const body = await readBody(req).then(b => JSON.parse(b || '{}')).catch(() => ({}));
417
+ const runId = body.runId || url.searchParams.get('runId');
418
+ if (!runId) {
419
+ sendJson(res, 400, { ok: false, error: 'runId is required' });
420
+ return true;
421
+ }
422
+ const result = store.durableJournal.resolveUncertain(id, runId, {
423
+ decision: body.decision || 'mark_failed',
424
+ note: body.note || '',
425
+ resolvedBy: body.resolvedBy || 'user',
426
+ });
427
+ if (!result.ok) {
428
+ sendJson(res, 400, result);
429
+ return true;
430
+ }
431
+ sendJson(res, 200, result);
432
+ return true;
433
+ }
410
434
  if (action === 'run') {
411
435
  if (!store.get(id)) {
412
436
  sendJson(res, 404, NOT_FOUND);
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+
5
+ export const SAFE_MIME_TYPES = {
6
+ '.csv': 'text/csv; charset=utf-8',
7
+ '.json': 'application/json; charset=utf-8',
8
+ '.txt': 'text/plain; charset=utf-8',
9
+ '.log': 'text/plain; charset=utf-8',
10
+ '.md': 'text/markdown; charset=utf-8',
11
+ '.pdf': 'application/pdf',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.webp': 'image/webp',
16
+ };
17
+
18
+ export class ArtifactManager {
19
+ constructor(baseDir) {
20
+ this.baseDir = baseDir || path.join(process.cwd(), 'artifacts');
21
+ this.maxArtifactBytes = 10 * 1024 * 1024; // 10 MiB per artifact
22
+ this.defaultRetentionDays = 7;
23
+ this.maxTotalStorageBytes = 50 * 1024 * 1024; // 50 MiB total quota
24
+ }
25
+
26
+ ensureDir() {
27
+ if (!fs.existsSync(this.baseDir)) {
28
+ fs.mkdirSync(this.baseDir, { recursive: true });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Sanitizes a requested filename to prevent path traversal (#321)
34
+ */
35
+ sanitizeFilename(name) {
36
+ if (!name || typeof name !== 'string') return 'artifact.txt';
37
+ const clean = path.basename(name).replace(/[\0\r\n\\\/]/g, '_');
38
+ return clean || 'artifact.txt';
39
+ }
40
+
41
+ getFilePath(artifactId, filename) {
42
+ if (!filename || filename.includes('..') || path.isAbsolute(filename)) {
43
+ throw new Error('Path traversal attempt detected in artifact storage');
44
+ }
45
+ const safeName = this.sanitizeFilename(filename);
46
+ const target = path.resolve(this.baseDir, `${artifactId}_${safeName}`);
47
+ // Security check: ensure path stays strictly within baseDir
48
+ if (!target.startsWith(path.resolve(this.baseDir) + path.sep)) {
49
+ throw new Error('Path traversal attempt detected in artifact storage');
50
+ }
51
+ return target;
52
+ }
53
+
54
+ /**
55
+ * Resolves safe MIME type for download/preview
56
+ */
57
+ resolveMimeType(filename) {
58
+ const ext = path.extname(filename).toLowerCase();
59
+ // Neutralize dangerous web formats to prevent XSS / script execution
60
+ if (ext === '.html' || ext === '.htm' || ext === '.svg' || ext === '.js') {
61
+ return 'text/plain; charset=utf-8';
62
+ }
63
+ return SAFE_MIME_TYPES[ext] || 'application/octet-stream';
64
+ }
65
+
66
+ /**
67
+ * Saves a new artifact file bound to a run (#321)
68
+ */
69
+ saveArtifact(runId, taskId, filename, contentBuffer) {
70
+ this.ensureDir();
71
+ const buf = Buffer.isBuffer(contentBuffer) ? contentBuffer : Buffer.from(contentBuffer || '', 'utf8');
72
+
73
+ if (buf.length > this.maxArtifactBytes) {
74
+ throw new Error(`Artifact size (${buf.length} bytes) exceeds limit of ${this.maxArtifactBytes} bytes`);
75
+ }
76
+
77
+ const artifactId = 'art_' + randomUUID().replace(/-/g, '').slice(0, 16);
78
+ const safeName = this.sanitizeFilename(filename);
79
+ const filePath = this.getFilePath(artifactId, safeName);
80
+
81
+ fs.writeFileSync(filePath, buf);
82
+
83
+ const sha256 = createHash('sha256').update(buf).digest('hex');
84
+ const mimeType = this.resolveMimeType(safeName);
85
+
86
+ return {
87
+ id: artifactId,
88
+ name: safeName,
89
+ sizeBytes: buf.length,
90
+ mimeType,
91
+ sha256,
92
+ runId,
93
+ taskId,
94
+ createdAt: Date.now(),
95
+ status: 'available',
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Reads an artifact file buffer
101
+ */
102
+ readArtifact(artifactId, filename) {
103
+ const filePath = this.getFilePath(artifactId, filename);
104
+ if (!fs.existsSync(filePath)) {
105
+ return null;
106
+ }
107
+ return fs.readFileSync(filePath);
108
+ }
109
+
110
+ /**
111
+ * Generates a safe preview of the artifact content (first 64 KiB)
112
+ */
113
+ generatePreview(artifactId, filename) {
114
+ const buf = this.readArtifact(artifactId, filename);
115
+ if (!buf) return { ok: false, error: 'Artifact file not found on disk' };
116
+
117
+ const ext = path.extname(filename).toLowerCase();
118
+ const isText = ['.txt', '.csv', '.json', '.log', '.md'].includes(ext);
119
+
120
+ if (isText) {
121
+ const slice = buf.subarray(0, 64 * 1024);
122
+ return {
123
+ ok: true,
124
+ kind: 'text',
125
+ mimeType: this.resolveMimeType(filename),
126
+ content: slice.toString('utf8'),
127
+ truncated: buf.length > slice.length,
128
+ sizeBytes: buf.length,
129
+ };
130
+ }
131
+
132
+ return {
133
+ ok: true,
134
+ kind: 'binary',
135
+ mimeType: this.resolveMimeType(filename),
136
+ sizeBytes: buf.length,
137
+ preview: `[Binary file: ${filename} (${buf.length} bytes)]`,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Cleans up expired artifacts and enforces size quotas (#321)
143
+ */
144
+ cleanupArtifacts(store, { retentionDays = this.defaultRetentionDays, maxTotalBytes = this.maxTotalStorageBytes } = {}) {
145
+ if (!fs.existsSync(this.baseDir)) return { removedCount: 0, freedBytes: 0 };
146
+
147
+ const cutoff = Date.now() - retentionDays * 86400000;
148
+ let files = fs.readdirSync(this.baseDir);
149
+ let totalBytes = 0;
150
+ const fileStats = [];
151
+
152
+ for (const f of files) {
153
+ const p = path.join(this.baseDir, f);
154
+ try {
155
+ const stat = fs.statSync(p);
156
+ totalBytes += stat.size;
157
+ fileStats.push({ path: p, filename: f, mtime: stat.mtimeMs, size: stat.size });
158
+ } catch (e) {
159
+ // file unlinked concurrently
160
+ }
161
+ }
162
+
163
+ let removedCount = 0;
164
+ let freedBytes = 0;
165
+
166
+ // 1. Remove files older than retentionDays
167
+ for (const item of fileStats) {
168
+ if (item.mtime < cutoff) {
169
+ try {
170
+ fs.unlinkSync(item.path);
171
+ removedCount++;
172
+ freedBytes += item.size;
173
+ totalBytes -= item.size;
174
+ } catch (e) {
175
+ void e;
176
+ }
177
+ }
178
+ }
179
+
180
+ // 2. Enforce quota: remove oldest files if totalBytes > maxTotalBytes
181
+ if (totalBytes > maxTotalBytes) {
182
+ fileStats.sort((a, b) => a.mtime - b.mtime);
183
+ for (const item of fileStats) {
184
+ if (totalBytes <= maxTotalBytes) break;
185
+ if (fs.existsSync(item.path)) {
186
+ try {
187
+ fs.unlinkSync(item.path);
188
+ removedCount++;
189
+ freedBytes += item.size;
190
+ totalBytes -= item.size;
191
+ } catch (e) {
192
+ void e;
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return { removedCount, freedBytes };
199
+ }
200
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Chain Checkpoint and Step Replay Manager (#319)
3
+ */
4
+
5
+ export class ChainCheckpointManager {
6
+ constructor(store) {
7
+ this.store = store;
8
+ this.activeRetries = new Set();
9
+ }
10
+
11
+ /**
12
+ * Records a snapshot of a completed or failed chain step
13
+ */
14
+ recordStepSnapshot(chainRunId, stepTaskId, snapshot) {
15
+ if (!chainRunId || !stepTaskId) return;
16
+ this.store.chainCheckpoints = this.store.chainCheckpoints || {};
17
+ this.store.chainCheckpoints[chainRunId] = this.store.chainCheckpoints[chainRunId] || [];
18
+
19
+ const list = this.store.chainCheckpoints[chainRunId];
20
+ const existingIndex = list.findIndex((s) => s.stepTaskId === stepTaskId);
21
+
22
+ const record = {
23
+ chainRunId,
24
+ stepTaskId,
25
+ status: snapshot.status || 'completed',
26
+ input: snapshot.input || {},
27
+ output: snapshot.output || '',
28
+ at: Date.now(),
29
+ durationMs: snapshot.durationMs || 0,
30
+ retryCount: (existingIndex >= 0 ? (list[existingIndex].retryCount || 0) + 1 : 0),
31
+ };
32
+
33
+ if (existingIndex >= 0) {
34
+ list[existingIndex] = record;
35
+ } else {
36
+ list.push(record);
37
+ }
38
+
39
+ this.store.scheduleSave(50);
40
+ return record;
41
+ }
42
+
43
+ /**
44
+ * Retrieves all step snapshots for a given chain run
45
+ */
46
+ getChainSnapshots(chainRunId) {
47
+ if (!chainRunId || !this.store.chainCheckpoints) return [];
48
+ return this.store.chainCheckpoints[chainRunId] || [];
49
+ }
50
+
51
+ /**
52
+ * Checks whether a step can be retried in a chain run
53
+ */
54
+ canRetryFromStep(chainRunId, stepTaskId) {
55
+ if (!chainRunId || !stepTaskId) return { ok: false, error: 'Missing chainRunId or stepTaskId' };
56
+
57
+ const lockKey = `${chainRunId}:${stepTaskId}`;
58
+ if (this.activeRetries.has(lockKey)) {
59
+ return { ok: false, error: 'Retry already in progress for this step' };
60
+ }
61
+
62
+ const snapshots = this.getChainSnapshots(chainRunId);
63
+ const stepSnapshot = snapshots.find((s) => s.stepTaskId === stepTaskId);
64
+ if (!stepSnapshot) {
65
+ return { ok: false, error: `No checkpoint found for step "${stepTaskId}" in chain "${chainRunId}"` };
66
+ }
67
+
68
+ if (stepSnapshot.status === 'success' || stepSnapshot.status === 'completed') {
69
+ return { ok: false, error: 'Step already completed successfully; cannot retry a successful step' };
70
+ }
71
+
72
+ const task = this.store.get(stepTaskId);
73
+ if (!task) {
74
+ return { ok: false, error: `Task "${stepTaskId}" was deleted or no longer exists` };
75
+ }
76
+
77
+ return { ok: true, task, stepSnapshot };
78
+ }
79
+
80
+ /**
81
+ * Retries chain execution from the specified failed step (#319)
82
+ * Reuses upstream outputs without re-executing successful upstream steps.
83
+ */
84
+ async retryChainFromStep(scheduler, chainRunId, stepTaskId) {
85
+ const check = this.canRetryFromStep(chainRunId, stepTaskId);
86
+ if (!check.ok) return check;
87
+
88
+ const lockKey = `${chainRunId}:${stepTaskId}`;
89
+ this.activeRetries.add(lockKey);
90
+
91
+ try {
92
+ const snapshots = this.getChainSnapshots(chainRunId);
93
+ // Find the nearest successful preceding step snapshot
94
+ const stepIndex = snapshots.findIndex((s) => s.stepTaskId === stepTaskId);
95
+ let prevSnapshot = null;
96
+ for (let i = stepIndex - 1; i >= 0; i--) {
97
+ if (snapshots[i].status === 'success' || snapshots[i].status === 'completed') {
98
+ prevSnapshot = snapshots[i];
99
+ break;
100
+ }
101
+ }
102
+
103
+ const prevOutput = prevSnapshot ? prevSnapshot.output : '';
104
+ const prevTaskId = prevSnapshot ? prevSnapshot.stepTaskId : '';
105
+
106
+ // Execute only the failed step, passing reconstructed chain context
107
+ const runFn = (scheduler.runTask || scheduler.runNow).bind(scheduler);
108
+ const res = await runFn(stepTaskId, {
109
+ chainRunId,
110
+ chainPrevOutput: prevOutput,
111
+ chainPrevTaskId: prevTaskId,
112
+ isRetry: true,
113
+ });
114
+
115
+ return {
116
+ ok: true,
117
+ chainRunId,
118
+ stepTaskId,
119
+ result: res,
120
+ };
121
+ } finally {
122
+ this.activeRetries.delete(lockKey);
123
+ }
124
+ }
125
+ }
package/lib/client.js CHANGED
@@ -140,8 +140,10 @@ window.__ModuleLoader__.load({
140
140
  'history.stage_validation': 'Validation',
141
141
  'history.stage_delivery': 'Delivery',
142
142
  'history.refresh': 'Refresh history',
143
- 'history.stage_delivery': '推送',
144
- 'history.statusSuccess': 'Success',
143
+ 'history.statusSuccess': 'Success', 'history.statusUncertain': 'Uncertain',
144
+ 'history.artifacts': 'Artifacts',
145
+ 'history.resolveCompleted': 'Mark Completed', 'history.resolveFailed': 'Mark Failed',
146
+ 'history.statusUncertainHint': 'Interrupted by restart — verify outcome',
145
147
  'history.statusSkipped': 'Skipped',
146
148
  'history.statusMissed': 'Missed',
147
149
  'history.statusTimeout': 'Timeout',
@@ -361,11 +363,9 @@ window.__ModuleLoader__.load({
361
363
  'form.targetSessionResetHint': 'Automatically rotates the session identifier to prevent context window explosion over long periods.',
362
364
  'form.requireApprovalLabel': 'Require human approval before execution',
363
365
  'form.requireApprovalHint': 'When enabled, the task will not execute until an authorized user approves the run proposal (#318).',
364
- 'actions.approve': 'Approve',
365
- 'actions.reject': 'Reject',
366
+ 'actions.approve': 'Approve', 'actions.reject': 'Reject',
366
367
  'approval.waiting': 'Awaiting human approval',
367
- 'approval.approved': 'Approved',
368
- 'approval.rejected': 'Rejected',
368
+ 'approval.approved': 'Approved', 'approval.rejected': 'Rejected',
369
369
  'recipe.dependenciesMissing': 'Missing required integrations or credentials',
370
370
  'form.previewSchedule': 'Preview next runs',
371
371
  'form.previewScheduleTitle': 'Next 5 scheduled runs:',
@@ -507,6 +507,10 @@ window.__ModuleLoader__.load({
507
507
  'history.summaryCost': '预估成本: ${cost}',
508
508
  'history.errorLabel': '错误详情:',
509
509
  'history.outputLabel': '执行输出:',
510
+ 'history.statusUncertain': '不确定',
511
+ 'history.artifacts': '产物文件',
512
+ 'history.resolveCompleted': '标记已完成', 'history.resolveFailed': '标记失败',
513
+ 'history.statusUncertainHint': '服务重启中断 — 请确认结果',
510
514
  'form.titleLabel': '任务名称 *',
511
515
  'form.titlePlaceholder': '例如: 每周备份检查',
512
516
  'form.typeLabel': '执行类型 *',
@@ -618,11 +622,9 @@ window.__ModuleLoader__.load({
618
622
  'confirm.delete': '确定删除该任务吗?',
619
623
  'form.requireApprovalLabel': '执行前需人工确认审批',
620
624
  'form.requireApprovalHint': '开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。',
621
- 'actions.approve': '批准',
622
- 'actions.reject': '拒绝',
625
+ 'actions.approve': '批准', 'actions.reject': '拒绝',
623
626
  'approval.waiting': '等待人工审批',
624
- 'approval.approved': '已批准',
625
- 'approval.rejected': '已拒绝',
627
+ 'approval.approved': '已批准', 'approval.rejected': '已拒绝',
626
628
  'recipe.dependenciesMissing': '缺少必要集成服务或凭据',
627
629
  'list.loadErrorPrefix': '加载任务列表失败'
628
630
  }
@@ -890,11 +892,6 @@ window.__ModuleLoader__.load({
890
892
  const STYLE_HEAL_KEY = '__dshCronStyleHeal';
891
893
  const STYLE_HEAL_INTERVAL_MS = 2000;
892
894
 
893
- /**
894
- * Keep the stylesheet attached: watch <head> (and <html>, which catches a
895
- * replaced <head>) and re-attach on demand, with a slow interval as the
896
- * backstop. heal() is idempotent and writes nothing while healthy.
897
- */
898
895
  function startStyleSelfHeal() {
899
896
  if (typeof document === 'undefined') return () => {};
900
897
  if (typeof window !== 'undefined' && typeof window[STYLE_HEAL_KEY] === 'function') {
@@ -997,7 +994,6 @@ window.__ModuleLoader__.load({
997
994
  const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
998
995
  const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
999
996
 
1000
- /** Collect every delivery key the settings endpoints may return. */
1001
997
  function parseEnvText(text) {
1002
998
  const env = {};
1003
999
  for (const rawLine of String(text || '').split('\n')) {
@@ -1091,15 +1087,10 @@ window.__ModuleLoader__.load({
1091
1087
  if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
1092
1088
  if (status === 'missed') return T_KEY(t, 'history.statusMissed');
1093
1089
  if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
1090
+ if (status === 'uncertain') return T_KEY(t, 'history.statusUncertain');
1094
1091
  return T_KEY(t, 'history.statusFailed');
1095
1092
  }
1096
1093
 
1097
- /**
1098
- * A config-declared task is owned by the profile config file (#50): the
1099
- * panel shows it, but editing, pausing or deleting it here would be undone
1100
- * at the next start, so the row keeps the source label instead of the
1101
- * actions that cannot stick.
1102
- */
1103
1094
  function isConfigManaged(task) {
1104
1095
  return Boolean(task) && task.managedBy === 'config';
1105
1096
  }
@@ -1107,10 +1098,6 @@ window.__ModuleLoader__.load({
1107
1098
  return (typeof t === 'function' ? t : translate)(key, vars);
1108
1099
  }
1109
1100
 
1110
- /**
1111
- * Issue #307: universal fetch with AbortController timeout (default 15s).
1112
- * Connects with caller signal if supplied and fails closed on timeouts.
1113
- */
1114
1101
  const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1115
1102
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1116
1103
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -1139,9 +1126,6 @@ window.__ModuleLoader__.load({
1139
1126
  }
1140
1127
  };
1141
1128
 
1142
- /**
1143
- * Issue #307: universal JSON fetch helper with timeout and aborted body protection.
1144
- */
1145
1129
  const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1146
1130
  const res = await fetchWithTimeout(url, options, timeoutMs);
1147
1131
  let data = {};
@@ -1166,11 +1150,6 @@ window.__ModuleLoader__.load({
1166
1150
  return out;
1167
1151
  }
1168
1152
 
1169
- /**
1170
- * Shared delivery form used by both the task panel modal and the plugin
1171
- * settings card, so a new channel is added in exactly one place.
1172
- * Sections collapse via the canonical aria-expanded head contract.
1173
- */
1174
1153
  function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
1175
1154
  const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
1176
1155
  const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
@@ -1247,7 +1226,6 @@ window.__ModuleLoader__.load({
1247
1226
  ];
1248
1227
  }
1249
1228
 
1250
- /** Parse a KEY=VALUE textarea into the task env object (#38). */
1251
1229
  function CronScreen(props) {
1252
1230
  const { ctx, toggle, onClose } = props;
1253
1231
  const t = props.t || translate;
@@ -1460,7 +1438,6 @@ window.__ModuleLoader__.load({
1460
1438
  return () => window.removeEventListener('keydown', onKey);
1461
1439
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
1462
1440
 
1463
- /** #40: facet filtering happens client-side over the loaded list. */
1464
1441
  const visibleTasks = React.useMemo(() => { let list = tasks;
1465
1442
  if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
1466
1443
  if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
@@ -1491,7 +1468,6 @@ window.__ModuleLoader__.load({
1491
1468
  setFilterChannel('');
1492
1469
  };
1493
1470
 
1494
- /** #34: scroll to and flash the task the sidebar list asked for. */
1495
1471
  React.useEffect(() => {
1496
1472
  if (!pendingTaskHighlight || tasks.length === 0) return;
1497
1473
  const id = pendingTaskHighlight;
@@ -1718,7 +1694,6 @@ window.__ModuleLoader__.load({
1718
1694
  }
1719
1695
  };
1720
1696
 
1721
- /** #41: duplicate a task; the server copies config, resets run state (#41). */
1722
1697
  const handleDryRun = async (taskId) => {
1723
1698
  try {
1724
1699
  const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
@@ -1777,10 +1752,8 @@ window.__ModuleLoader__.load({
1777
1752
  }
1778
1753
  };
1779
1754
 
1780
- /** A task id must not shadow a collection route (#42 review finding). */
1781
1755
  const RESERVED_TASK_IDS = ['export', 'import'];
1782
1756
 
1783
- /** Import needs the script-confirm header when the file has code tasks. */
1784
1757
  const isCodeTaskForm = (x) => {
1785
1758
  if (!x || typeof x !== 'object') return false;
1786
1759
  if (CODE_FORM_TYPES.includes(x.type)) return true;
@@ -1797,7 +1770,6 @@ window.__ModuleLoader__.load({
1797
1770
  return headers;
1798
1771
  };
1799
1772
 
1800
- /** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
1801
1773
  const handleExport = async () => {
1802
1774
  setTransferBusy(true);
1803
1775
  try {
@@ -1827,7 +1799,6 @@ window.__ModuleLoader__.load({
1827
1799
  }
1828
1800
  };
1829
1801
 
1830
- /** #42: read the picked file, validate it server-side and show the plan. */
1831
1802
  const handleImportFile = async (e) => {
1832
1803
  const file = e.target.files && e.target.files[0];
1833
1804
  e.target.value = '';
@@ -1884,7 +1855,6 @@ window.__ModuleLoader__.load({
1884
1855
  }
1885
1856
  };
1886
1857
 
1887
- /** #49: tune an existing task in a dialogue instead of editing fields. */
1888
1858
  const handleTuneWithDsh = async (task) => {
1889
1859
  const lines = [
1890
1860
  `Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
@@ -2007,11 +1977,6 @@ window.__ModuleLoader__.load({
2007
1977
  }
2008
1978
  };
2009
1979
 
2010
- /**
2011
- * Update one delivery setting without dropping the rest of the object.
2012
- * `undefined` removes the key so the server default applies again; an
2013
- * empty string is kept so a text field can be cleared on purpose.
2014
- */
2015
1980
  const updateDelivery = (key, value) => {
2016
1981
  setSettingsDelivery((prev) => {
2017
1982
  const next = Object.assign({}, prev);
@@ -2021,7 +1986,6 @@ window.__ModuleLoader__.load({
2021
1986
  });
2022
1987
  };
2023
1988
 
2024
- /** Per-channel template overrides live in a nested map on the server. */
2025
1989
  const updateChannelTemplate = (channelId, value) => {
2026
1990
  setSettingsDelivery((prev) => {
2027
1991
  const templates = Object.assign({}, prev.channelTemplates || {});
@@ -2195,6 +2159,23 @@ window.__ModuleLoader__.load({
2195
2159
  }
2196
2160
  };
2197
2161
 
2162
+ const handleResolveUncertain = async (taskId, runId, resolution) => {
2163
+ try {
2164
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/resolve-uncertain', {
2165
+ method: 'POST',
2166
+ headers: { 'Content-Type': 'application/json' },
2167
+ body: JSON.stringify({ runId, resolution }),
2168
+ });
2169
+ if (data && data.ok) {
2170
+ await loadTaskHistory(taskId);
2171
+ } else {
2172
+ alert(data?.error || 'Failed to resolve uncertain run');
2173
+ }
2174
+ } catch (e) {
2175
+ alert(e.message || 'Error resolving uncertain run');
2176
+ }
2177
+ };
2178
+
2198
2179
  const loadTaskHistory = async (taskId) => {
2199
2180
  setHistoryLoading(true);
2200
2181
  try {
@@ -2828,6 +2809,41 @@ window.__ModuleLoader__.load({
2828
2809
  onClick: () => handleRetryDelivery(formId, run.id)
2829
2810
  }, retryingDeliveryId === run.id ? T_KEY(t, 'history.retryingDelivery') : T_KEY(t, 'history.retryDelivery'))
2830
2811
  ),
2812
+ run.artifacts && run.artifacts.length > 0 && React.createElement('div', {
2813
+ style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px', alignItems: 'center' }
2814
+ },
2815
+ React.createElement('span', { className: 'dsh-cron-hint', style: { fontSize: '11px' } }, '📦 ' + T_KEY(t, 'history.artifacts') + ':'),
2816
+ run.artifacts.map((art) => React.createElement('a', {
2817
+ key: art.name || art.id,
2818
+ href: '/dsh-cron/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(art.name) + '/preview',
2819
+ target: '_blank',
2820
+ rel: 'noopener noreferrer',
2821
+ className: 'dsh-cron-btn-secondary',
2822
+ style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none' },
2823
+ title: art.name + ' (' + Math.round((art.size || 0) / 1024 * 10) / 10 + ' KB)'
2824
+ }, art.name))
2825
+ ),
2826
+ run.status === 'uncertain' && React.createElement('div', {
2827
+ className: 'dsh-cron-status-banner',
2828
+ 'data-type': 'warning',
2829
+ style: { marginTop: '8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }
2830
+ },
2831
+ React.createElement('span', null, '⚠️ ' + T_KEY(t, 'history.statusUncertainHint')),
2832
+ React.createElement('div', { style: { display: 'flex', gap: '6px' } },
2833
+ React.createElement('button', {
2834
+ type: 'button',
2835
+ className: 'dsh-cron-btn-secondary',
2836
+ style: { padding: '2px 8px', fontSize: '11px' },
2837
+ onClick: () => handleResolveUncertain(formId, run.id, 'completed')
2838
+ }, '✓ ' + T_KEY(t, 'history.resolveCompleted')),
2839
+ React.createElement('button', {
2840
+ type: 'button',
2841
+ className: 'dsh-cron-btn-secondary',
2842
+ style: { padding: '2px 8px', fontSize: '11px' },
2843
+ onClick: () => handleResolveUncertain(formId, run.id, 'failed')
2844
+ }, '✕ ' + T_KEY(t, 'history.resolveFailed'))
2845
+ )
2846
+ ),
2831
2847
  run.diagnosis && React.createElement('div', {
2832
2848
  className: 'dsh-cron-status-banner',
2833
2849
  'data-type': 'info',
@@ -3351,7 +3367,7 @@ window.__ModuleLoader__.load({
3351
3367
  React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
3352
3368
  )
3353
3369
  ),
3354
-
3370
+
3355
3371
  React.createElement('div', {
3356
3372
  style: {
3357
3373
  marginTop: '16px',
@@ -3485,7 +3501,6 @@ window.__ModuleLoader__.load({
3485
3501
  )
3486
3502
  ),
3487
3503
 
3488
-
3489
3504
  dryRunResult && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setDryRunResult(null) },
3490
3505
  React.createElement('div', {
3491
3506
  className: 'dsh-cron-modal',
@@ -4167,18 +4182,9 @@ window.__ModuleLoader__.load({
4167
4182
  return entry;
4168
4183
  }
4169
4184
 
4170
- /**
4171
- * Collapsible list of active jobs under the sidebar entry (#34).
4172
- *
4173
- * Vanilla DOM like the entry itself: the sidebar is not a React root here.
4174
- * Collapsed by default, state persisted, capped at a few rows with an
4175
- * "and N more" line so a busy schedule cannot push the rest of the sidebar
4176
- * off screen. Clicking a row opens the panel and highlights that task.
4177
- */
4178
4185
  const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
4179
4186
  const SIDEBAR_JOBS_MAX_ROWS = 5;
4180
4187
 
4181
- /** Sidebar job row: title plus next run time or the live running state. */
4182
4188
  function buildSidebarJobRow(state, task) {
4183
4189
  const row = document.createElement('button');
4184
4190
  row.type = 'button';
@@ -4274,7 +4280,6 @@ window.__ModuleLoader__.load({
4274
4280
  return root;
4275
4281
  }
4276
4282
 
4277
- /** Keep the section mounted under the sidebar entry across re-renders. */
4278
4283
  function placeSidebarJobs(state, label) {
4279
4284
  if (state.root !== undefined && state.root.isConnected) return;
4280
4285
  const entry = document.querySelector('[' + ENTRY_ATTR + ']');
@@ -4290,14 +4295,6 @@ window.__ModuleLoader__.load({
4290
4295
  applySidebarJobsOpen(state);
4291
4296
  }
4292
4297
 
4293
- /**
4294
- * Collapsible list of active jobs under the sidebar entry (#34).
4295
- *
4296
- * Vanilla DOM like the entry itself: the sidebar is not a React root here.
4297
- * Collapsed by default, state persisted, capped at a few rows with an
4298
- * "and N more" line so a busy schedule cannot push the rest of the sidebar
4299
- * off screen. Clicking a row opens the panel and highlights that task.
4300
- */
4301
4298
  function mountSidebarJobs(toggle, label, openTaskSignal) {
4302
4299
  if (typeof document === 'undefined') return () => {};
4303
4300
  const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
@@ -4437,12 +4434,6 @@ window.__ModuleLoader__.load({
4437
4434
  };
4438
4435
  }
4439
4436
 
4440
- /**
4441
- * Register a card into a DSH mount point. Mount points are populated via
4442
- * ctx.slots.inject(mount, cb) — a direct slots.register does not throw but
4443
- * also never appears in the target surface (settings plugins tab, header),
4444
- * so the inject contract is mandatory (#85).
4445
- */
4446
4437
  function registerIntoMount(ctx, mountName, entry, component) {
4447
4438
  if (!ctx || !ctx.slots || typeof ctx.slots.inject !== 'function') return false;
4448
4439
  try {
@@ -0,0 +1,188 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * States for durable task execution lifecycle (#317)
5
+ */
6
+ export const JOURNAL_STATES = {
7
+ QUEUED: 'queued',
8
+ RUNNING: 'running',
9
+ COMPLETED: 'completed',
10
+ FAILED: 'failed',
11
+ CANCELLED: 'cancelled',
12
+ UNCERTAIN: 'uncertain',
13
+ };
14
+
15
+ export class DurableJournal {
16
+ constructor(store) {
17
+ this.store = store;
18
+ }
19
+
20
+ /**
21
+ * Generates a deterministic idempotency key for a task run
22
+ */
23
+ generateIdempotencyKey(taskId, scheduleTick) {
24
+ const raw = `${taskId}:${scheduleTick || Date.now()}`;
25
+ return 'idem_' + createHash('sha256').update(raw).digest('hex').slice(0, 24);
26
+ }
27
+
28
+ /**
29
+ * Checks whether a run with this idempotency key was already completed
30
+ */
31
+ isIdempotencyKeyExecuted(taskId, idempotencyKey) {
32
+ if (!idempotencyKey) return null;
33
+ const history = this.store.getHistory(taskId) || [];
34
+ return history.find(
35
+ (r) => r.idempotencyKey === idempotencyKey &&
36
+ (r.status === JOURNAL_STATES.COMPLETED || r.status === 'success')
37
+ ) || null;
38
+ }
39
+
40
+ /**
41
+ * Pre-allocates a durable run entry before execution starts (#317)
42
+ */
43
+ createRunRecord(taskId, { idempotencyKey, chainRunId, trigger = 'scheduler', scheduleTick } = {}) {
44
+ const runId = 'run_' + randomUUID().replace(/-/g, '').slice(0, 16);
45
+ const key = idempotencyKey || this.generateIdempotencyKey(taskId, scheduleTick);
46
+
47
+ const record = {
48
+ id: runId,
49
+ taskId,
50
+ at: Date.now(),
51
+ status: JOURNAL_STATES.QUEUED,
52
+ trigger,
53
+ idempotencyKey: key,
54
+ chainRunId: chainRunId || null,
55
+ stages: [
56
+ { name: 'queue', status: 'success', startedAt: Date.now(), finishedAt: Date.now() },
57
+ { name: 'preflight', status: 'pending' },
58
+ { name: 'execution', status: 'pending' },
59
+ { name: 'validation', status: 'pending' },
60
+ { name: 'delivery', status: 'pending' },
61
+ ],
62
+ stoppedReason: null,
63
+ createdAt: Date.now(),
64
+ };
65
+
66
+ // Record initial queued entry into history/store
67
+ this.store.recordRun(taskId, record);
68
+ return record;
69
+ }
70
+
71
+ /**
72
+ * Transitions a run record to a new state and updates history entry
73
+ */
74
+ transition(taskId, runId, toState, metadata = {}) {
75
+ const runs = this.store.history.get(taskId) || [];
76
+ const index = runs.findIndex((r) => r.id === runId);
77
+ if (index === -1) return null;
78
+
79
+ const existing = runs[index];
80
+ const updated = {
81
+ ...existing,
82
+ ...metadata,
83
+ status: toState,
84
+ updatedAt: Date.now(),
85
+ };
86
+
87
+ if (
88
+ toState === JOURNAL_STATES.COMPLETED ||
89
+ toState === JOURNAL_STATES.FAILED ||
90
+ toState === JOURNAL_STATES.CANCELLED ||
91
+ toState === JOURNAL_STATES.UNCERTAIN
92
+ ) {
93
+ updated.finishedAt = Date.now();
94
+ if (updated.startedAt && !updated.durationMs) {
95
+ updated.durationMs = updated.finishedAt - updated.startedAt;
96
+ }
97
+ }
98
+
99
+ runs[index] = updated;
100
+ this.store.history.set(taskId, runs);
101
+ this.store.scheduleSave(50);
102
+ return updated;
103
+ }
104
+
105
+ /**
106
+ * Recovers uncompleted / in-flight runs on daemon restart (#317)
107
+ */
108
+ recoverPendingRuns(scheduler) {
109
+ const tasks = this.store.list();
110
+ const recovered = { uncertain: [], requeued: [] };
111
+
112
+ for (const task of tasks) {
113
+ const history = this.store.history.get(task.id) || [];
114
+ let historyChanged = false;
115
+
116
+ for (let i = 0; i < history.length; i++) {
117
+ const run = history[i];
118
+ if (run.status === JOURNAL_STATES.RUNNING) {
119
+ // Crash during execution -> transition to UNCERTAIN to prevent blind rerun
120
+ history[i] = {
121
+ ...run,
122
+ status: JOURNAL_STATES.UNCERTAIN,
123
+ stoppedReason: 'interrupted_by_restart',
124
+ uncertainAt: Date.now(),
125
+ };
126
+ historyChanged = true;
127
+ recovered.uncertain.push({ taskId: task.id, runId: run.id });
128
+ } else if (run.status === JOURNAL_STATES.QUEUED) {
129
+ // Queued run that didn't start yet: if task is still active and recent (<1 hour), re-enqueue
130
+ const ageMs = Date.now() - (run.createdAt || run.at);
131
+ if (task.status === 'active' && ageMs < 3600000 && scheduler && typeof scheduler.runTask === 'function') {
132
+ scheduler.queue = scheduler.queue || [];
133
+ if (!scheduler.queue.includes(task.id)) {
134
+ scheduler.queue.push(task.id);
135
+ recovered.requeued.push({ taskId: task.id, runId: run.id });
136
+ }
137
+ } else {
138
+ // Expired or cancelled queued run
139
+ history[i] = {
140
+ ...run,
141
+ status: JOURNAL_STATES.CANCELLED,
142
+ stoppedReason: 'Queued run expired or cancelled across restart',
143
+ };
144
+ historyChanged = true;
145
+ }
146
+ }
147
+ }
148
+
149
+ if (historyChanged) {
150
+ this.store.scheduleSave(50);
151
+ }
152
+ }
153
+
154
+ return recovered;
155
+ }
156
+
157
+ /**
158
+ * Manually resolves an uncertain run record (#317)
159
+ */
160
+ resolveUncertain(taskId, runId, { decision, note, resolvedBy = 'user' } = {}) {
161
+ const runs = this.store.history.get(taskId) || [];
162
+ const index = runs.findIndex((r) => r.id === runId);
163
+ if (index === -1) return { ok: false, error: 'Run not found' };
164
+
165
+ const run = runs[index];
166
+ if (run.status !== JOURNAL_STATES.UNCERTAIN) {
167
+ return { ok: false, error: `Run is in status "${run.status}", not uncertain` };
168
+ }
169
+
170
+ let nextStatus = JOURNAL_STATES.FAILED;
171
+ if (decision === 'completed' || decision === 'mark_completed') nextStatus = JOURNAL_STATES.COMPLETED;
172
+ else if (decision === 'failed' || decision === 'mark_failed') nextStatus = JOURNAL_STATES.FAILED;
173
+ else if (decision === 'cancel' || decision === 'cancelled') nextStatus = JOURNAL_STATES.CANCELLED;
174
+
175
+ const resolved = {
176
+ ...run,
177
+ status: nextStatus,
178
+ stoppedReason: `Uncertain run resolved by ${resolvedBy}: ${note || decision}`,
179
+ resolvedBy,
180
+ resolvedAt: Date.now(),
181
+ };
182
+
183
+ runs[index] = resolved;
184
+ this.store.history.set(taskId, runs);
185
+ this.store.scheduleSave(50);
186
+ return { ok: true, run: resolved };
187
+ }
188
+ }
package/lib/routes.js CHANGED
@@ -44,6 +44,21 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
44
44
  handler: createCronApiHandler(store, scheduler, null)
45
45
  }), 'dsh-cron: /schedule[...]');
46
46
 
47
+
48
+ // Chain retry from failed step (#319)
49
+ ctx.effect(() => ctx.webServer.register({
50
+ kind: 'prefix',
51
+ path: '/dsh-cron/chains',
52
+ handler: (req, res) => handleChainRoutes(store, scheduler, req, res)
53
+ }), 'dsh-cron: /chains[...]');
54
+
55
+ // Run artifacts listing, download and safe preview (#321)
56
+ ctx.effect(() => ctx.webServer.register({
57
+ kind: 'prefix',
58
+ path: '/dsh-cron/runs',
59
+ handler: (req, res) => handleRunArtifactRoutes(store, req, res)
60
+ }), 'dsh-cron: /runs[...]');
61
+
47
62
  // GET /dsh-cron/metrics (#53) — Prometheus text exposition
48
63
  ctx.effect(() => ctx.webServer.register({
49
64
  kind: 'exact',
@@ -198,3 +213,72 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
198
213
  }), 'dsh-cron: /kanban/test');
199
214
  }
200
215
 
216
+
217
+ /**
218
+ * Handler for chain step retries (#319)
219
+ */
220
+ export async function handleChainRoutes(store, scheduler, req, res) {
221
+ if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
222
+ const url = new URL(req.url, 'http://localhost');
223
+ const m = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)\/retry-from\/([^\/]+)$/);
224
+ if (!m) {
225
+ sendJson(res, 404, { ok: false, error: 'Route not found' });
226
+ return;
227
+ }
228
+ if (req.method !== 'POST') {
229
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
230
+ return;
231
+ }
232
+ const [, chainRunId, stepTaskId] = m;
233
+ const result = await store.chainCheckpointManager.retryChainFromStep(scheduler, chainRunId, stepTaskId);
234
+ if (!result.ok) {
235
+ sendJson(res, 400, result);
236
+ return;
237
+ }
238
+ sendJson(res, 200, result);
239
+ }
240
+
241
+ /**
242
+ * Handler for run artifacts (#321)
243
+ */
244
+ export async function handleRunArtifactRoutes(store, req, res) {
245
+ const url = new URL(req.url, 'http://localhost');
246
+ const m = url.pathname.match(/^\/dsh-cron\/runs\/([^\/]+)\/artifacts(?:\/([^\/]+)(?:\/(preview))?)?$/);
247
+ if (!m) {
248
+ sendJson(res, 404, { ok: false, error: 'Route not found' });
249
+ return;
250
+ }
251
+ if (req.method !== 'GET') {
252
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
253
+ return;
254
+ }
255
+ const [, runId, artifactId, action] = m;
256
+ if (!artifactId) {
257
+ const artifacts = store.getArtifactsForRun(runId);
258
+ sendJson(res, 200, { ok: true, runId, artifacts });
259
+ return;
260
+ }
261
+ const artifacts = store.getArtifactsForRun(runId);
262
+ const record = artifacts.find((a) => a.id === artifactId);
263
+ if (!record) {
264
+ sendJson(res, 404, { ok: false, error: 'Artifact record not found' });
265
+ return;
266
+ }
267
+ if (action === 'preview') {
268
+ const preview = store.artifactManager.generatePreview(artifactId, record.name);
269
+ sendJson(res, preview.ok ? 200 : 404, preview);
270
+ return;
271
+ }
272
+ const buf = store.artifactManager.readArtifact(artifactId, record.name);
273
+ if (!buf) {
274
+ sendJson(res, 404, { ok: false, error: 'Artifact file missing from disk (expired or removed)' });
275
+ return;
276
+ }
277
+ res.writeHead(200, {
278
+ 'Content-Type': record.mimeType || 'application/octet-stream',
279
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(record.name)}"`,
280
+ 'Content-Length': buf.length,
281
+ 'X-Content-Type-Options': 'nosniff',
282
+ });
283
+ res.end(buf);
284
+ }
@@ -310,6 +310,23 @@ export async function executeTask(scheduler, taskId, options = {}) {
310
310
  model: outcome.model || task.model || '',
311
311
  };
312
312
  }
313
+ // 0. Idempotency Check (#317): deduplicate runs with an already completed idempotency key
314
+ if (options.idempotencyKey && scheduler.store.durableJournal) {
315
+ const existing = scheduler.store.durableJournal.isIdempotencyKeyExecuted(taskId, options.idempotencyKey);
316
+ if (existing) {
317
+ logger.info(`[dsh-cron] Idempotency key "${options.idempotencyKey}" already executed for task "${taskId}", returning cached run`);
318
+ return {
319
+ ok: existing.status === 'completed' || existing.status === 'success',
320
+ idempotentReplay: true,
321
+ runId: existing.id,
322
+ status: existing.status,
323
+ output: existing.output,
324
+ costUsd: existing.costUsd,
325
+ durationMs: existing.durationMs,
326
+ };
327
+ }
328
+ }
329
+
313
330
 
314
331
  // 1. Approval Gate (#318): Human-in-the-loop check before executing actions requiring approval
315
332
  if (task.requireApproval && !options.approvedBy) {
@@ -699,6 +716,46 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
699
716
  kind: (Number(outcome.costUsd) > 0) ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact'),
700
717
  };
701
718
  runInfo.stoppedReason = computeStoppedReason(outcome.status, valRes, outcome.error);
719
+ // Durable journal & Idempotency (#317)
720
+ runInfo.idempotencyKey = options.idempotencyKey || currentRun?.idempotencyKey || null;
721
+
722
+ // Artifacts capture (#321)
723
+ if (Array.isArray(outcome.artifacts) && scheduler.store.artifactManager) {
724
+ for (const art of outcome.artifacts) {
725
+ if (art && art.name && (art.content || art.data)) {
726
+ try {
727
+ scheduler.store.artifactManager.saveArtifact(runInfo.runId, taskId, art.name, art.content || art.data);
728
+ } catch (artErr) {
729
+ logger.warn(`[dsh-cron] failed to save artifact ${art.name}:`, artErr.message);
730
+ }
731
+ }
732
+ }
733
+ }
734
+ if (scheduler.store.getArtifactsForRun) {
735
+ runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
736
+ }
737
+
738
+ // Chain Checkpoints (#319)
739
+ const chainedTarget = outcome.status === 'success' ? task.onSuccess : task.onFailure;
740
+ const chainRunId = options.chainRunId || (chainedTarget ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
741
+ if (chainRunId) {
742
+ runInfo.chainRunId = chainRunId;
743
+ if (scheduler.store.chainCheckpointManager) {
744
+ scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, taskId, {
745
+ status: outcome.status,
746
+ input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '' },
747
+ output: outcome.output || outcome.error || '',
748
+ durationMs: executionMs,
749
+ });
750
+ }
751
+ }
752
+
753
+ // Update Durable Journal (#317)
754
+ if (scheduler.store.durableJournal) {
755
+ const journalState = outcome.status === 'success' ? 'completed' : (outcome.status === 'uncertain' ? 'uncertain' : 'failed');
756
+ scheduler.store.durableJournal.transition(taskId, runInfo.runId, journalState, runInfo);
757
+ }
758
+
702
759
 
703
760
  try {
704
761
  scheduler.store.recordRun(taskId, runInfo);
@@ -795,6 +852,7 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
795
852
  if (nextTask) {
796
853
  logger.info(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
797
854
  scheduler.runNow(targetId, {
855
+ chainRunId,
798
856
  chainDepth: currentDepth + 1,
799
857
  prevOutput: outcome.output || outcome.error || '',
800
858
  prevTaskId: task.id,
package/lib/scheduler.js CHANGED
@@ -361,6 +361,26 @@ export class TaskScheduler {
361
361
  start() {
362
362
  this.isStopped = false;
363
363
  this.generation = (this.generation || 0) + 1;
364
+
365
+ // Recovery & cleanup on restart (#317, #321)
366
+ if (this.store && this.store.durableJournal) {
367
+ try {
368
+ const recovered = this.store.durableJournal.recoverPendingRuns(this);
369
+ if (recovered.uncertain.length > 0) {
370
+ logger.warn(`[dsh-cron] Recovered ${recovered.uncertain.length} in-flight run(s) interrupted by restart -> status marked uncertain`);
371
+ }
372
+ } catch (recErr) {
373
+ logger.error('[dsh-cron] restart recovery failed:', recErr.message);
374
+ }
375
+ }
376
+ if (this.store && this.store.artifactManager) {
377
+ try {
378
+ this.store.artifactManager.cleanupArtifacts(this.store);
379
+ } catch (cleanErr) {
380
+ logger.warn('[dsh-cron] artifact cleanup failed:', cleanErr.message);
381
+ }
382
+ }
383
+
364
384
  const tasks = this.store.list();
365
385
  const now = Date.now();
366
386
  for (const task of tasks) {
package/lib/store.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { DurableJournal } from './durable-journal.js';
2
+ import { ChainCheckpointManager } from './chain-checkpoints.js';
3
+ import { ArtifactManager } from './artifacts.js';
1
4
  import { logger } from './logger.js';
2
5
  import { bestEffort } from './best-effort.js';
3
6
  import fs from 'node:fs';
@@ -36,6 +39,12 @@ export class TaskStore {
36
39
  this.tasks = new Map();
37
40
  this.history = new Map();
38
41
  this.settings = {};
42
+ this.chainCheckpoints = {};
43
+ this.artifacts = {};
44
+ const storeDir = path.dirname(this.filePath);
45
+ this.durableJournal = new DurableJournal(this);
46
+ this.chainCheckpointManager = new ChainCheckpointManager(this);
47
+ this.artifactManager = new ArtifactManager(path.join(storeDir, "artifacts"));
39
48
  this._lastBackupTime = 0;
40
49
  this._saveTimer = null;
41
50
  this._savePromise = Promise.resolve();
@@ -85,6 +94,12 @@ export class TaskStore {
85
94
  if (data.settings && typeof data.settings === 'object') {
86
95
  this.settings = data.settings;
87
96
  }
97
+ if (data.chainCheckpoints && typeof data.chainCheckpoints === 'object') {
98
+ this.chainCheckpoints = data.chainCheckpoints;
99
+ }
100
+ if (data.artifacts && typeof data.artifacts === 'object') {
101
+ this.artifacts = data.artifacts;
102
+ }
88
103
  };
89
104
 
90
105
  if (fs.existsSync(this.filePath)) {
@@ -189,6 +204,8 @@ export class TaskStore {
189
204
  tasks: tasksSnapshot,
190
205
  history: historySnapshot,
191
206
  settings: this.settings ? { ...this.settings } : {},
207
+ chainCheckpoints: this.chainCheckpoints ? { ...this.chainCheckpoints } : {},
208
+ artifacts: this.artifacts ? { ...this.artifacts } : {},
192
209
  };
193
210
  }
194
211
 
@@ -347,7 +364,10 @@ export class TaskStore {
347
364
  yield ']';
348
365
  await new Promise((r) => setImmediate(r));
349
366
  }
350
- yield '}}';
367
+ yield '}';
368
+ yield ',"chainCheckpoints":' + JSON.stringify(data.chainCheckpoints || {});
369
+ yield ',"artifacts":' + JSON.stringify(data.artifacts || {});
370
+ yield '}';
351
371
  }
352
372
 
353
373
  try {
@@ -407,6 +427,32 @@ export class TaskStore {
407
427
  }
408
428
  }
409
429
 
430
+ getRun(runId) {
431
+ if (!runId) return null;
432
+ for (const [taskId, runs] of this.history.entries()) {
433
+ if (Array.isArray(runs)) {
434
+ const found = runs.find((r) => r.id === runId);
435
+ if (found) return { ...found, taskId };
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ recordArtifact(runIdOrRecord, maybeRecord) {
442
+ const runId = maybeRecord ? runIdOrRecord : (runIdOrRecord && runIdOrRecord.runId);
443
+ const artifactRecord = maybeRecord ? maybeRecord : runIdOrRecord;
444
+ if (!runId || !artifactRecord) return;
445
+ this.artifacts = this.artifacts || {};
446
+ this.artifacts[runId] = this.artifacts[runId] || [];
447
+ this.artifacts[runId].push(artifactRecord);
448
+ this.scheduleSave(50);
449
+ }
450
+
451
+ getArtifactsForRun(runId) {
452
+ if (!runId || !this.artifacts) return [];
453
+ return this.artifacts[runId] || [];
454
+ }
455
+
410
456
  getSettings() {
411
457
  const defaults = getDshDefaultTelegramCredentials();
412
458
  const base = { ...this.settings };
@@ -706,6 +752,9 @@ export class TaskStore {
706
752
  },
707
753
  delivery: runInfo.delivery || null,
708
754
  validation: runInfo.validation || null,
755
+ idempotencyKey: runInfo.idempotencyKey || null,
756
+ chainRunId: runInfo.chainRunId || null,
757
+ artifacts: runInfo.artifacts || null,
709
758
  sessionId: runInfo.sessionId || null,
710
759
  // #45: which model produced the run, and whether it only finished thanks
711
760
  // to the configured fallback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.52",
3
+ "version": "0.3.0",
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",