@goodandready/dsh-cron 0.3.25 → 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 (82) 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 -191
  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/commands.js +200 -0
  32. package/lib/durable-journal.js +1 -188
  33. package/lib/external-api.js +1 -74
  34. package/lib/index.js +17 -0
  35. package/lib/metrics.js +1 -125
  36. package/lib/routes/api-actions.js +317 -0
  37. package/lib/routes/api-collection.js +250 -0
  38. package/lib/routes/api-helpers.js +100 -0
  39. package/lib/routes/api-import-export.js +155 -0
  40. package/lib/routes/api-item.js +191 -0
  41. package/lib/routes/api-task-webhook.js +247 -0
  42. package/lib/routes/api-webhook.js +183 -0
  43. package/lib/routes/api.js +178 -0
  44. package/lib/routes/external-api.js +74 -0
  45. package/lib/routes/index.js +11 -0
  46. package/lib/routes/metrics.js +125 -0
  47. package/lib/routes/routes.js +549 -0
  48. package/lib/routes.js +1 -549
  49. package/lib/runner-external.js +1 -352
  50. package/lib/runner-worktree.js +1 -90
  51. package/lib/runner.js +1 -881
  52. package/lib/runtimes/index.js +4 -0
  53. package/lib/runtimes/runner-external.js +352 -0
  54. package/lib/runtimes/runner-worktree.js +90 -0
  55. package/lib/runtimes/runner.js +881 -0
  56. package/lib/runtimes/runtimes.js +198 -0
  57. package/lib/runtimes.js +1 -198
  58. package/lib/scheduler/index.js +6 -0
  59. package/lib/scheduler/scheduler-assists.js +142 -0
  60. package/lib/scheduler/scheduler-completion.js +417 -0
  61. package/lib/scheduler/scheduler-execution.js +360 -0
  62. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  63. package/lib/scheduler/scheduler-preflight.js +195 -0
  64. package/lib/scheduler/scheduler.js +751 -0
  65. package/lib/scheduler-assists.js +1 -142
  66. package/lib/scheduler-completion.js +1 -417
  67. package/lib/scheduler-execution.js +1 -360
  68. package/lib/scheduler-lifecycle.js +1 -157
  69. package/lib/scheduler-preflight.js +1 -195
  70. package/lib/scheduler.js +1 -751
  71. package/lib/store/artifacts.js +200 -0
  72. package/lib/store/chain-checkpoints.js +223 -0
  73. package/lib/store/durable-journal.js +188 -0
  74. package/lib/store/index.js +6 -0
  75. package/lib/store/store.js +1179 -0
  76. package/lib/store/task-schema.js +180 -0
  77. package/lib/store/task-transfer.js +310 -0
  78. package/lib/store.js +2 -1179
  79. package/lib/task-schema.js +1 -180
  80. package/lib/task-transfer.js +1 -310
  81. package/lib/tui-settings.js +157 -0
  82. package/package.json +2 -1
package/lib/metrics.js CHANGED
@@ -1,125 +1 @@
1
- import { rejectCrossOrigin } from './http-utils.js';
2
- /**
3
- * Prometheus exposition of the scheduler (#53).
4
- *
5
- * The renderer is hand-rolled on purpose: the profile has no prom-client and
6
- * adding a production dependency for a handful of counters is not justified.
7
- * Only counts, statuses and durations are exported — never prompts, run output
8
- * or task configuration, so the endpoint can be scraped without leaking work
9
- * content.
10
- */
11
-
12
- export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
13
-
14
- /** Run statuses the scheduler can finish with. */
15
- export const RUN_STATUSES = ['success', 'error', 'timeout', 'skipped', 'missed'];
16
-
17
- const NL = String.fromCharCode(10);
18
- const BACKSLASH = String.fromCharCode(92);
19
- const QUOTE = String.fromCharCode(34);
20
-
21
- /** Escape a label value for the Prometheus text format. */
22
- export function escapeLabel(value) {
23
- return String(value)
24
- .split(BACKSLASH).join(BACKSLASH + BACKSLASH)
25
- .split(QUOTE).join(BACKSLASH + QUOTE)
26
- .split(NL).join(BACKSLASH + 'n');
27
- }
28
-
29
- function sample(name, labels, value) {
30
- const rendered = Object.keys(labels)
31
- .map((key) => key + '=' + QUOTE + escapeLabel(labels[key]) + QUOTE)
32
- .join(',');
33
- return name + (rendered ? '{' + rendered + '}' : '') + ' ' + value;
34
- }
35
-
36
- /**
37
- * Render the current scheduler state as Prometheus text exposition.
38
- * Pure function over plain data so it is testable without a store.
39
- */
40
- export function renderMetrics({ tasks = [], stats = {}, runCounters = {}, scheduler } = {}) {
41
- const lines = [];
42
- const byStatus = {};
43
- for (const task of tasks) {
44
- const status = task.status || 'unknown';
45
- byStatus[status] = (byStatus[status] || 0) + 1;
46
- }
47
-
48
- lines.push('# HELP dsh_cron_tasks_total Scheduled tasks by status.');
49
- // A gauge despite the name: this is a snapshot of how many tasks are in each
50
- // state, not a monotonically growing total.
51
- lines.push('# TYPE dsh_cron_tasks_total gauge');
52
- for (const status of Object.keys(byStatus).sort()) {
53
- lines.push(sample('dsh_cron_tasks_total', { status }, byStatus[status]));
54
- }
55
-
56
- lines.push('# HELP dsh_cron_task_last_duration_seconds Duration of the last finished run of a task.');
57
- lines.push('# TYPE dsh_cron_task_last_duration_seconds gauge');
58
- for (const task of tasks) {
59
- // lastRunAt marks a finished run: a task that never ran still carries
60
- // lastDurationMs = 0, and exporting that would show instant runs in
61
- // monitoring.
62
- if (!Number.isFinite(task.lastDurationMs) || !task.lastRunAt) continue;
63
- lines.push(sample('dsh_cron_task_last_duration_seconds', { task: task.id }, task.lastDurationMs / 1000));
64
- }
65
-
66
- lines.push('# HELP dsh_cron_runs_total Finished runs since the plugin started, by status.');
67
- lines.push('# TYPE dsh_cron_runs_total counter');
68
- for (const status of RUN_STATUSES) {
69
- lines.push(sample('dsh_cron_runs_total', { status }, Number(runCounters[status]) || 0));
70
- }
71
-
72
- lines.push('# HELP dsh_cron_run_records Run records currently kept in memory.');
73
- lines.push('# TYPE dsh_cron_run_records gauge');
74
- lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
75
-
76
- if (scheduler && typeof scheduler.runningCount === 'function') {
77
- lines.push('# HELP dsh_cron_concurrent_running Number of currently executing tasks.');
78
- lines.push('# TYPE dsh_cron_concurrent_running gauge');
79
- lines.push('dsh_cron_concurrent_running ' + (Number(scheduler.runningCount()) || 0));
80
- }
81
-
82
- lines.push('# HELP dsh_cron_task_tokens_total Cumulative tokens consumed by task.');
83
- lines.push('# TYPE dsh_cron_task_tokens_total counter');
84
- for (const task of tasks) {
85
- if (Number(task.totalTokens) > 0) {
86
- lines.push(sample('dsh_cron_task_tokens_total', { task: task.id }, task.totalTokens));
87
- }
88
- }
89
-
90
- lines.push('# HELP dsh_cron_task_cost_usd_total Estimated cumulative LLM cost in USD.');
91
- lines.push('# TYPE dsh_cron_task_cost_usd_total counter');
92
- for (const task of tasks) {
93
- if (Number(task.totalCostUsd) > 0) {
94
- lines.push(sample('dsh_cron_task_cost_usd_total', { task: task.id }, task.totalCostUsd));
95
- }
96
- }
97
-
98
- return lines.join(NL) + NL;
99
- }
100
-
101
- /**
102
- * GET /dsh-cron/metrics — read-only. A scraper is a plain local client, so no
103
- * cross-origin check applies, and nothing sensitive is exposed.
104
- */
105
- export function createMetricsHandler({ store, scheduler }) {
106
- return function handleMetrics(req, res) {
107
- if (req.method !== 'GET' && req.method !== 'HEAD') {
108
- res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
109
- res.end('Method not allowed' + NL);
110
- return;
111
- }
112
- const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
113
- const apiToken = settings.apiToken;
114
- if (rejectCrossOrigin(req, res, { apiToken })) return;
115
-
116
- const body = renderMetrics({
117
- tasks: store.list({ status: 'all' }),
118
- stats: store.getAggregatedStats(),
119
- runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
120
- scheduler,
121
- });
122
- res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
123
- res.end(req.method === 'HEAD' ? undefined : body);
124
- };
125
- }
1
+ export * from './routes/metrics.js';
@@ -0,0 +1,317 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { captureGitDiff, discardGitChanges, applyGitDiff } from '../git-diff.js';
3
+ import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from '../doctor.js';
4
+ import { rollbackPromptVersion } from '../prompt-evolver.js';
5
+ import { approvalManager } from '../approval.js';
6
+ import { MANAGED_BY_CONFIG } from '../config-jobs.js';
7
+ import { sendJson, readBody, redactTaskSecrets } from '../http-utils.js';
8
+ import { buildDuplicateTask } from '../task-transfer.js';
9
+ import { refuseConfigOwned, NOT_FOUND } from './api-collection.js';
10
+
11
+ export async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
12
+ if (action === 'git-diff' && (subAction === 'discard' || subAction === 'revert')) {
13
+ const task = store.get(id);
14
+ if (!task) {
15
+ sendJson(res, 404, NOT_FOUND);
16
+ return true;
17
+ }
18
+ const targetDir = task.cwd;
19
+ if (!targetDir) {
20
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
21
+ return true;
22
+ }
23
+ const result = await discardGitChanges(targetDir);
24
+ sendJson(res, result.ok ? 200 : 500, result);
25
+ return true;
26
+ }
27
+
28
+ if (action === 'git-diff' && subAction === 'apply') {
29
+ const task = store.get(id);
30
+ if (!task) {
31
+ sendJson(res, 404, NOT_FOUND);
32
+ return true;
33
+ }
34
+ const body = await readBody(req, res);
35
+ const bodyData = (body && body.body) || {};
36
+ let patch = bodyData.diff || bodyData.patch;
37
+ if (!patch) {
38
+ const runs = store.getHistory(id, 5);
39
+ const targetRun = runs.find((r) => r.gitDiff?.diff);
40
+ patch = targetRun?.gitDiff?.diff;
41
+ }
42
+ if (!patch) {
43
+ sendJson(res, 400, { ok: false, error: 'No diff patch provided or found in run history' });
44
+ return true;
45
+ }
46
+ const targetDir = task.cwd;
47
+ if (!targetDir) {
48
+ sendJson(res, 400, { ok: false, error: 'Task does not define a working directory (cwd)' });
49
+ return true;
50
+ }
51
+ const result = await applyGitDiff(targetDir, patch);
52
+ sendJson(res, result.ok ? 200 : 500, result);
53
+ return true;
54
+ }
55
+
56
+ if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
57
+ const task = store.get(id);
58
+ if (!task) {
59
+ sendJson(res, 404, NOT_FOUND);
60
+ return true;
61
+ }
62
+ const body = await readBody(req, res);
63
+ const bodyData = (body && body.body) || {};
64
+ let fix = bodyData.fix || bodyData.doctorReport;
65
+ if (!fix) {
66
+ const runs = store.getHistory(id, 5);
67
+ const reportRun = runs.find((r) => r.doctorReport);
68
+ fix = reportRun?.doctorReport;
69
+ }
70
+ if (!fix || !fix.suggestedFix) {
71
+ sendJson(res, 400, { ok: false, error: 'No doctor fix found to apply' });
72
+ return true;
73
+ }
74
+ try {
75
+ const result = await applyDoctorFix(task, fix, { store, user: bodyData.user || 'operator' });
76
+ if (!result.ok) {
77
+ sendJson(res, 400, { ok: false, error: result.error });
78
+ return true;
79
+ }
80
+ sendJson(res, 200, {
81
+ ok: true,
82
+ taskId: id,
83
+ task: redactTaskSecrets(task),
84
+ revision: result.revision,
85
+ });
86
+ } catch (err) {
87
+ sendJson(res, 500, { ok: false, error: err.message });
88
+ }
89
+ return true;
90
+ }
91
+
92
+ if ((action === 'doctor' && subAction === 'revert') || action === 'doctor-revert') {
93
+ const task = store.get(id);
94
+ if (!task) {
95
+ sendJson(res, 404, NOT_FOUND);
96
+ return true;
97
+ }
98
+ const body = await readBody(req, res);
99
+ const bodyData = (body && body.body) || {};
100
+ try {
101
+ const result = await revertDoctorFix(task, { store, user: bodyData.user || 'operator' });
102
+ if (!result.ok) {
103
+ sendJson(res, 400, { ok: false, error: result.error });
104
+ return true;
105
+ }
106
+ sendJson(res, 200, {
107
+ ok: true,
108
+ taskId: id,
109
+ task: redactTaskSecrets(task),
110
+ revertedCode: result.revertedCode,
111
+ });
112
+ } catch (err) {
113
+ sendJson(res, 500, { ok: false, error: err.message });
114
+ }
115
+ return true;
116
+ }
117
+
118
+ if ((action === 'doctor' && subAction === 'diagnose') || action === 'doctor-diagnose') {
119
+ const task = store.get(id);
120
+ if (!task) {
121
+ sendJson(res, 404, NOT_FOUND);
122
+ return true;
123
+ }
124
+ const body = await readBody(req, res);
125
+ const bodyData = (body && body.body) || {};
126
+ const runId = bodyData.runId;
127
+ const runs = store.getHistory(id, 10);
128
+ const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
129
+ if (!targetRun) {
130
+ sendJson(res, 404, { ok: false, error: 'No run history found to diagnose' });
131
+ return true;
132
+ }
133
+ try {
134
+ const doctorReport = await diagnoseFailure({
135
+ task,
136
+ runInfo: targetRun,
137
+ ask: scheduler?.askModel,
138
+ });
139
+ targetRun.doctorReport = doctorReport;
140
+ sendJson(res, 200, { ok: true, taskId: id, doctorReport });
141
+ } catch (err) {
142
+ sendJson(res, 500, { ok: false, error: err.message });
143
+ }
144
+ return true;
145
+ }
146
+ if (action === 'prompt-rollback') {
147
+ const task = store.get(id);
148
+ if (!task) {
149
+ sendJson(res, 404, NOT_FOUND);
150
+ return true;
151
+ }
152
+ const body = await readBodyJson(req).catch(() => ({}));
153
+ const targetVersion = Number(body.version || url.searchParams.get('version'));
154
+ if (!targetVersion || targetVersion < 1) {
155
+ sendJson(res, 400, { ok: false, error: 'A valid target prompt version is required' });
156
+ return true;
157
+ }
158
+ try {
159
+ rollbackPromptVersion(task, targetVersion);
160
+ store.set(task);
161
+ sendJson(res, 200, {
162
+ ok: true,
163
+ task: redactTaskSecrets(task),
164
+ promptVersion: task.promptVersion,
165
+ promptHistory: task.promptHistory,
166
+ });
167
+ } catch (err) {
168
+ sendJson(res, 400, { ok: false, error: err.message });
169
+ }
170
+ return true;
171
+ }
172
+ if (action === 'retry-delivery') {
173
+ const task = store.get(id);
174
+ if (!task) {
175
+ sendJson(res, 404, NOT_FOUND);
176
+ return true;
177
+ }
178
+ const body = await readBodyJson(req).catch(() => ({}));
179
+ const runId = body.runId || url.searchParams.get('runId');
180
+ const runs = store.history.get(id) || [];
181
+ const run = runId ? runs.find(r => r.id === runId) : runs[0];
182
+ if (!run) {
183
+ sendJson(res, 404, { ok: false, error: 'Run not found' });
184
+ return true;
185
+ }
186
+ const delivery = await scheduler.deliverNotifications(task, run);
187
+ run.delivery = delivery;
188
+ if (Array.isArray(run.stages)) {
189
+ const deliveryStage = run.stages.find(s => s.name === 'delivery');
190
+ if (deliveryStage) {
191
+ deliveryStage.status = delivery?.status || 'delivered';
192
+ deliveryStage.delivered = delivery?.delivered;
193
+ deliveryStage.failures = delivery?.failures;
194
+ }
195
+ }
196
+ store.scheduleSave();
197
+ sendJson(res, 200, { ok: true, delivery, run });
198
+ return true;
199
+ }
200
+ if (action === 'approve' || action === 'reject') {
201
+ const task = store.get(id);
202
+ if (!task) {
203
+ sendJson(res, 404, NOT_FOUND);
204
+ return true;
205
+ }
206
+ const body = await readBodyJson(req).catch(() => ({}));
207
+ const proposalId = body.proposalId || url.searchParams.get('proposalId');
208
+ const proposal = proposalId
209
+ ? approvalManager.getProposal(proposalId)
210
+ : approvalManager.getPendingProposalForTask(id);
211
+
212
+ if (!proposal) {
213
+ sendJson(res, 404, { ok: false, error: 'No pending approval proposal found for this task' });
214
+ return true;
215
+ }
216
+
217
+ const decision = action === 'approve' ? 'approve' : 'reject';
218
+ const result = approvalManager.decide(proposal.id, decision, {
219
+ decidedBy: body.decidedBy || 'web-user',
220
+ reason: body.reason || '',
221
+ });
222
+
223
+ if (!result.ok) {
224
+ sendJson(res, 400, result);
225
+ return true;
226
+ }
227
+
228
+ if (decision === 'approve') {
229
+ scheduler.runTask(id, { approvedBy: body.decidedBy || 'web-user' }).catch(() => {});
230
+ }
231
+
232
+ sendJson(res, 200, { ok: true, proposal: result.proposal });
233
+ return true;
234
+ }
235
+ if (action === 'resolve-uncertain') {
236
+ const task = store.get(id);
237
+ if (!task) {
238
+ sendJson(res, 404, NOT_FOUND);
239
+ return true;
240
+ }
241
+ const body = await readBody(req).then(b => JSON.parse(b || '{}')).catch(() => ({}));
242
+ const runId = body.runId || url.searchParams.get('runId');
243
+ if (!runId) {
244
+ sendJson(res, 400, { ok: false, error: 'runId is required' });
245
+ return true;
246
+ }
247
+ const result = store.durableJournal.resolveUncertain(id, runId, {
248
+ decision: body.decision || 'mark_failed',
249
+ note: body.note || '',
250
+ resolvedBy: body.resolvedBy || 'user',
251
+ });
252
+ if (!result.ok) {
253
+ sendJson(res, 400, result);
254
+ return true;
255
+ }
256
+ sendJson(res, 200, result);
257
+ return true;
258
+ }
259
+ if (action === 'run') {
260
+ if (!store.get(id)) {
261
+ sendJson(res, 404, NOT_FOUND);
262
+ return true;
263
+ }
264
+ await scheduler.triggerManualRun(id);
265
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(store.get(id)) });
266
+ return true;
267
+ }
268
+ if (action === 'duplicate') {
269
+ const source = store.get(id);
270
+ if (!source) {
271
+ sendJson(res, 404, NOT_FOUND);
272
+ return true;
273
+ }
274
+ const copy = buildDuplicateTask(source, { id: randomUUID() });
275
+ try {
276
+ store.set(copy);
277
+ } catch (err) {
278
+ sendJson(res, 500, { ok: false, error: 'Failed to persist duplicate task: ' + err.message });
279
+ return true;
280
+ }
281
+ // A copy starts paused: it must never fire on its own before the user
282
+ // reviews the schedule (a duplicated one-shot may point at a past time).
283
+ scheduler.pauseTask(copy.id);
284
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(copy) });
285
+ return true;
286
+ }
287
+ if (action === 'pause' || action === 'resume') {
288
+ if (store.get(id) && store.get(id).managedBy === MANAGED_BY_CONFIG) {
289
+ refuseConfigOwned(res, id);
290
+ return true;
291
+ }
292
+ const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
293
+ if (!task) {
294
+ sendJson(res, 404, NOT_FOUND);
295
+ return true;
296
+ }
297
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
298
+ return true;
299
+ }
300
+ if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
301
+ const stored = store.get(id);
302
+ if (!stored) {
303
+ sendJson(res, 404, NOT_FOUND);
304
+ return true;
305
+ }
306
+ if (stored.managedBy === MANAGED_BY_CONFIG) {
307
+ refuseConfigOwned(res, id);
308
+ return true;
309
+ }
310
+ const task = scheduler.toggleTask(id);
311
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
312
+ return true;
313
+ }
314
+ return false;
315
+ }
316
+
317
+