@goodandready/dsh-cron 0.3.24 → 0.3.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.
Files changed (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
@@ -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,223 @@
1
+ /**
2
+ * Chain Checkpoint, Step Replay, and DAG Barrier Manager (#319, #328)
3
+ */
4
+
5
+ import { sanitizeDependsOn, sanitizeJoinPolicy } from '../dag.js';
6
+
7
+ export class ChainCheckpointManager {
8
+ constructor(store) {
9
+ this.store = store;
10
+ this.activeRetries = new Set();
11
+ this.triggeredBarriers = new Set();
12
+ }
13
+
14
+ /**
15
+ * Records a snapshot of a completed or failed chain step
16
+ */
17
+ recordStepSnapshot(chainRunId, stepTaskId, snapshot) {
18
+ if (!chainRunId || !stepTaskId) return;
19
+ this.store.chainCheckpoints = this.store.chainCheckpoints || {};
20
+ this.store.chainCheckpoints[chainRunId] = this.store.chainCheckpoints[chainRunId] || [];
21
+
22
+ const list = this.store.chainCheckpoints[chainRunId];
23
+ const existingIndex = list.findIndex((s) => s.stepTaskId === stepTaskId);
24
+
25
+ const record = {
26
+ chainRunId,
27
+ stepTaskId,
28
+ status: snapshot.status || 'completed',
29
+ input: snapshot.input || {},
30
+ output: snapshot.output || '',
31
+ at: Date.now(),
32
+ durationMs: snapshot.durationMs || 0,
33
+ retryCount: (existingIndex >= 0 ? (list[existingIndex].retryCount || 0) + 1 : 0),
34
+ };
35
+
36
+ if (existingIndex >= 0) {
37
+ list[existingIndex] = record;
38
+ } else {
39
+ list.push(record);
40
+ }
41
+
42
+ this.store.scheduleSave(50);
43
+ return record;
44
+ }
45
+
46
+ /**
47
+ * Retrieves all step snapshots for a given chain run
48
+ */
49
+ getChainSnapshots(chainRunId) {
50
+ if (!chainRunId || !this.store.chainCheckpoints) return [];
51
+ return this.store.chainCheckpoints[chainRunId] || [];
52
+ }
53
+
54
+ /**
55
+ * Checks whether all prerequisites for a task are satisfied (DAG Join Barrier, #328).
56
+ *
57
+ * @param {string} chainRunId
58
+ * @param {Object} task
59
+ * @returns {{ ready: boolean, pending?: boolean, failed?: boolean, outputs: Record<string, string>, latestOutput: string, waitingFor?: string[], failedDep?: string }}
60
+ */
61
+ checkJoinBarrier(chainRunId, task) {
62
+ if (!task) return { ready: false, outputs: {}, latestOutput: '' };
63
+ const deps = sanitizeDependsOn(task.dependsOn);
64
+ if (!deps.length) {
65
+ return { ready: true, outputs: {}, latestOutput: '' };
66
+ }
67
+
68
+ const snapshots = this.getChainSnapshots(chainRunId);
69
+ const snapMap = new Map();
70
+ for (const s of snapshots) {
71
+ if (s?.stepTaskId) snapMap.set(s.stepTaskId, s);
72
+ }
73
+
74
+ const policy = sanitizeJoinPolicy(task.joinPolicy);
75
+ const outputs = {};
76
+ let latestOutput = '';
77
+ const waitingFor = [];
78
+ let hasSuccess = false;
79
+ let failedDep = null;
80
+
81
+ for (const depId of deps) {
82
+ const snap = snapMap.get(depId);
83
+ if (!snap) {
84
+ waitingFor.push(depId);
85
+ continue;
86
+ }
87
+ const isOk = snap.status === 'completed' || snap.status === 'success';
88
+ if (isOk) {
89
+ hasSuccess = true;
90
+ outputs[depId] = String(snap.output || '');
91
+ latestOutput = String(snap.output || '');
92
+ } else {
93
+ failedDep = depId;
94
+ }
95
+ }
96
+
97
+ if (policy === 'any_success') {
98
+ if (hasSuccess) {
99
+ return { ready: true, outputs, latestOutput };
100
+ }
101
+ if (waitingFor.length === 0) {
102
+ return { ready: false, failed: true, failedDep: failedDep || deps[0], outputs, latestOutput };
103
+ }
104
+ return { ready: false, pending: true, waitingFor, outputs, latestOutput };
105
+ }
106
+
107
+ // Default: 'all_success'
108
+ if (waitingFor.length > 0) {
109
+ return { ready: false, pending: true, waitingFor, outputs, latestOutput };
110
+ }
111
+ if (failedDep) {
112
+ return { ready: false, failed: true, failedDep, outputs, latestOutput };
113
+ }
114
+
115
+ return { ready: true, outputs, latestOutput };
116
+ }
117
+
118
+ /**
119
+ * Barrier idempotency guard: returns true if barrier has not yet fired for this task in this run.
120
+ */
121
+ canTriggerBarrier(chainRunId, taskId) {
122
+ if (!chainRunId || !taskId) return false;
123
+ return !this.triggeredBarriers.has(`${chainRunId}:${taskId}`);
124
+ }
125
+
126
+ /**
127
+ * Marks a barrier as triggered for this chain run to prevent duplicate executions.
128
+ */
129
+ markBarrierTriggered(chainRunId, taskId) {
130
+ if (!chainRunId || !taskId) return;
131
+ this.triggeredBarriers.add(`${chainRunId}:${taskId}`);
132
+ }
133
+
134
+ /**
135
+ * Checks whether a step can be retried in a chain run
136
+ */
137
+ canRetryFromStep(chainRunId, stepTaskId) {
138
+ if (!chainRunId || !stepTaskId) return { ok: false, error: 'Missing chainRunId or stepTaskId' };
139
+
140
+ const lockKey = `${chainRunId}:${stepTaskId}`;
141
+ if (this.activeRetries.has(lockKey)) {
142
+ return { ok: false, error: 'Retry already in progress for this step' };
143
+ }
144
+
145
+ const snapshots = this.getChainSnapshots(chainRunId);
146
+ const stepSnapshot = snapshots.find((s) => s.stepTaskId === stepTaskId);
147
+ if (!stepSnapshot) {
148
+ return { ok: false, error: `No checkpoint found for step "${stepTaskId}" in chain "${chainRunId}"` };
149
+ }
150
+
151
+ if (stepSnapshot.status === 'success' || stepSnapshot.status === 'completed') {
152
+ return { ok: false, error: 'Step already completed successfully; cannot retry a successful step' };
153
+ }
154
+
155
+ const task = this.store.get(stepTaskId);
156
+ if (!task) {
157
+ return { ok: false, error: `Task "${stepTaskId}" was deleted or no longer exists` };
158
+ }
159
+
160
+ return { ok: true, task, stepSnapshot };
161
+ }
162
+
163
+ /**
164
+ * Retries chain execution from the specified failed step (#319, #328)
165
+ * Reuses upstream outputs without re-executing successful upstream steps.
166
+ */
167
+ async retryChainFromStep(scheduler, chainRunId, stepTaskId) {
168
+ const check = this.canRetryFromStep(chainRunId, stepTaskId);
169
+ if (!check.ok) return check;
170
+
171
+ const lockKey = `${chainRunId}:${stepTaskId}`;
172
+ this.activeRetries.add(lockKey);
173
+
174
+ try {
175
+ const snapshots = this.getChainSnapshots(chainRunId);
176
+
177
+ // Collect upstream outputs from checkpoints
178
+ const prevOutputs = {};
179
+ let prevOutput = '';
180
+ let prevTaskId = '';
181
+
182
+ for (const s of snapshots) {
183
+ if (s.stepTaskId !== stepTaskId && (s.status === 'success' || s.status === 'completed')) {
184
+ prevOutputs[s.stepTaskId] = String(s.output || '');
185
+ prevOutput = String(s.output || '');
186
+ prevTaskId = s.stepTaskId;
187
+ }
188
+ }
189
+
190
+ // Reset barrier locks for stepTaskId and downstream dependents so they can re-trigger
191
+ this.triggeredBarriers.delete(`${chainRunId}:${stepTaskId}`);
192
+ if (this.store?.tasks) {
193
+ for (const t of Object.values(this.store.tasks)) {
194
+ const deps = sanitizeDependsOn(t?.dependsOn);
195
+ if (deps.includes(stepTaskId)) {
196
+ this.triggeredBarriers.delete(`${chainRunId}:${t.id}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Execute only the failed step, passing reconstructed chain context
202
+ const runFn = (scheduler.runTask || scheduler.runNow).bind(scheduler);
203
+ const res = await runFn(stepTaskId, {
204
+ chainRunId,
205
+ prevOutputs,
206
+ chainPrevOutput: prevOutput,
207
+ prevOutput,
208
+ chainPrevTaskId: prevTaskId,
209
+ prevTaskId,
210
+ isRetry: true,
211
+ });
212
+
213
+ return {
214
+ ok: true,
215
+ chainRunId,
216
+ stepTaskId,
217
+ result: res,
218
+ };
219
+ } finally {
220
+ this.activeRetries.delete(lockKey);
221
+ }
222
+ }
223
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export * from './store.js';
2
+ export * from './durable-journal.js';
3
+ export * from './artifacts.js';
4
+ export * from './chain-checkpoints.js';
5
+ export * from './task-schema.js';
6
+ export * from './task-transfer.js';