@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.
- package/lib/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -190
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/client.js +48 -3
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/log-streamer.js +10 -2
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +6 -3
package/lib/external-api.js
CHANGED
|
@@ -1,74 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* External REST surface under /dsh-cron/api/* (#54, ADR-0001).
|
|
3
|
-
*
|
|
4
|
-
* Everything a browser panel needs stays on the local, cross-origin-protected
|
|
5
|
-
* routes; this prefix exists for CI and host-side automation, so it is the only
|
|
6
|
-
* surface guarded by a bearer token. The token lives in the plugin settings as
|
|
7
|
-
* a masked value: an unconfigured token disables the surface (503) instead of
|
|
8
|
-
* leaving it open, and a wrong token is answered with 401.
|
|
9
|
-
*
|
|
10
|
-
* The operations themselves are shared with the panel routes (lib/api.js), so
|
|
11
|
-
* the confirmation gate for code-executing tasks and the config-owned refusals
|
|
12
|
-
* apply here exactly as they do over HTTP from the UI.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
16
|
-
import { sendJson } from './http-utils.js';
|
|
17
|
-
import { handleExternalTaskRequest } from './api.js';
|
|
18
|
-
|
|
19
|
-
/** Settings key that holds the bearer token. */
|
|
20
|
-
export const API_TOKEN_SETTING_KEY = 'apiToken';
|
|
21
|
-
|
|
22
|
-
/** Route prefix owned by this surface. */
|
|
23
|
-
export const EXTERNAL_API_PREFIX = '/dsh-cron/api';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Constant-time token comparison. Length is compared first because
|
|
27
|
-
* timingSafeEqual throws on buffers of different sizes; a length mismatch is
|
|
28
|
-
* not a secret worth hiding for a fixed-length token.
|
|
29
|
-
*/
|
|
30
|
-
export function tokenMatches(expected, provided) {
|
|
31
|
-
if (!expected || !provided) return false;
|
|
32
|
-
const a = Buffer.from(String(expected), 'utf8');
|
|
33
|
-
const b = Buffer.from(String(provided), 'utf8');
|
|
34
|
-
if (a.length !== b.length) return false;
|
|
35
|
-
return timingSafeEqual(a, b);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Bearer token of a request, or an empty string when there is none. */
|
|
39
|
-
export function readBearerToken(req) {
|
|
40
|
-
const header = String((req.headers && req.headers.authorization) || '');
|
|
41
|
-
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
42
|
-
return match ? match[1].trim() : '';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Guard the external prefix, then hand the request to the shared task
|
|
47
|
-
* operations. `getToken` is read per request so a settings change applies
|
|
48
|
-
* without a restart.
|
|
49
|
-
*/
|
|
50
|
-
export function createExternalApiHandler({ store, scheduler, getToken }) {
|
|
51
|
-
return async function handleExternalApi(req, res) {
|
|
52
|
-
const expected = typeof getToken === 'function' ? getToken() : '';
|
|
53
|
-
if (!expected) {
|
|
54
|
-
sendJson(res, 503, {
|
|
55
|
-
ok: false,
|
|
56
|
-
error: 'External API is disabled: set the "' + API_TOKEN_SETTING_KEY + '" setting to enable it',
|
|
57
|
-
});
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (!tokenMatches(expected, readBearerToken(req))) {
|
|
61
|
-
sendJson(res, 401, { ok: false, error: 'Unauthorized' });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
65
|
-
// `url` must be inside the try: a malformed body (an unparseable schedule,
|
|
66
|
-
// for example) surfaces as a thrown error from the shared handlers, and an
|
|
67
|
-
// unhandled rejection here would answer nothing at all.
|
|
68
|
-
try {
|
|
69
|
-
await handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken: expected });
|
|
70
|
-
} catch (err) {
|
|
71
|
-
sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
}
|
|
1
|
+
export * from './routes/external-api.js';
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { applyTuiSettings, buildTuiSection } from './tui-settings.js';
|
|
2
|
+
import { registerCommands, executeCronCommand } from './commands.js';
|
|
3
|
+
export { applyTuiSettings, buildTuiSection, registerCommands, executeCronCommand };
|
|
1
4
|
import {
|
|
2
5
|
TaskSchema,
|
|
3
6
|
validateTaskRecord,
|
|
@@ -376,6 +379,20 @@ export function apply(ctx, rawConfig) {
|
|
|
376
379
|
}), 'dsh-cron: /settings');
|
|
377
380
|
|
|
378
381
|
// 2. Tools: unified background automation runner (Issue #196: 9 tools -> 1)
|
|
382
|
+
// TUI settings section (#387)
|
|
383
|
+
applyTuiSettings(ctx, {
|
|
384
|
+
store,
|
|
385
|
+
scheduler,
|
|
386
|
+
live: () => ({
|
|
387
|
+
...unwrapConfig(getContextConfig(ctx, rawConfig)),
|
|
388
|
+
...activeVolatileConfig,
|
|
389
|
+
...store.getSettings(),
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Slash commands (#388)
|
|
394
|
+
registerCommands(ctx, { store, scheduler });
|
|
395
|
+
|
|
379
396
|
ctx.tools.register(defineTool({
|
|
380
397
|
name: 'cron',
|
|
381
398
|
description: 'Background automation runner for DSH: isolated agent runs, script/node/python/http/ssh/docker runtimes, cost guard, notifications, heartbeats. For simple reminders in the current chat, use the built-in schedule_create tool instead. Actions: create, list, get, update, pause, resume, run, delete.',
|
package/lib/log-streamer.js
CHANGED
|
@@ -50,12 +50,15 @@ export class LogStreamer extends EventEmitter {
|
|
|
50
50
|
ts: Date.now(),
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
entry.eventSeq = (entry.eventSeq || 0) + 1;
|
|
54
|
+
item.id = entry.eventSeq;
|
|
55
|
+
|
|
53
56
|
entry.buffer.push(item);
|
|
54
57
|
if (entry.buffer.length > MAX_BUFFER_LINES) {
|
|
55
58
|
entry.buffer.shift();
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
const ssePayload = `
|
|
61
|
+
const ssePayload = `id: ${item.id}\nevent: log\ndata: ${JSON.stringify(item)}\n\n`;
|
|
59
62
|
for (const res of entry.subscribers) {
|
|
60
63
|
try {
|
|
61
64
|
res.write(ssePayload);
|
|
@@ -111,12 +114,17 @@ export class LogStreamer extends EventEmitter {
|
|
|
111
114
|
|
|
112
115
|
const entry = this.streams.get(taskId);
|
|
113
116
|
|
|
117
|
+
// Support W3C Last-Event-ID for reconnection (#392)
|
|
118
|
+
const lastEventIdStr = req?.headers ? req.headers['last-event-id'] : undefined;
|
|
119
|
+
const lastEventId = lastEventIdStr ? parseInt(lastEventIdStr, 10) : 0;
|
|
120
|
+
const effectiveLogs = (entry?.buffer || []).filter((item) => !lastEventId || (item.id && item.id > lastEventId));
|
|
121
|
+
|
|
114
122
|
// Initial event with current state and buffered logs
|
|
115
123
|
const initData = {
|
|
116
124
|
taskId,
|
|
117
125
|
runId: entry?.runId || null,
|
|
118
126
|
status: entry ? (entry.completed ? 'completed' : 'running') : 'idle',
|
|
119
|
-
bufferedLogs:
|
|
127
|
+
bufferedLogs: effectiveLogs,
|
|
120
128
|
};
|
|
121
129
|
res.write(`event: init\ndata: ${JSON.stringify(initData)}\n\n`);
|
|
122
130
|
|
package/lib/metrics.js
CHANGED
|
@@ -1,125 +1 @@
|
|
|
1
|
-
|
|
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
|
+
|