@goodandready/dsh-cron 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,6 +90,18 @@ graph TD
90
90
 
91
91
  ## ✨ Features & Capabilities
92
92
 
93
+ ### 🔀 Decision Nodes & Conditional IF/ELSE Branching (#352)
94
+ - **Conditional Routing**: Tasks can define `branchRules` evaluating execution outcomes (`exitCode`, `output`, `output.json.<path>`, `status`) with operators (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
95
+ - **Fallback Else**: Optional `elseTask` triggers when no branch condition matches.
96
+ - **Deadlock-Free Synchronization**: Untriggered branch tasks are safely recorded with `status: 'skipped'`, unblocking downstream join barriers (`joinPolicy: 'all_success'`).
97
+ - **Graph Visualization**: DAG Graph View displays decision badges (`🔀 Decision`) and condition labels on connecting edges.
98
+
99
+ ### 🩺 Autonomous Self-Healing Doctor (#353)
100
+ - **Failure Classification**: Automatic detection of 8 failure categories: `syntax_error`, `dependency_missing`, `rate_limit`, `auth_failed`, `schema_change`, `timeout`, `network_error`, `runtime_error`.
101
+ - **Unified Diff**: Clean line-by-line diff preview of proposed fixes before application.
102
+ - **1-Click Apply & Revert**: Dedicated `/doctor/apply` and `/doctor/revert` endpoints with full revision backup (up to 10 versions preserved in `task.revisions`).
103
+ - **Guarded Autonomous Auto-Healing**: When `autoHeal: true`, high-confidence fixes are applied automatically with a strict 24-hour rate limit to prevent repair loops.
104
+
93
105
  ### 1. Visual Task Manager & Sidebar Action
94
106
  Click the clock icon in the DSH sidebar (positioned next to the new-session button) to open the management panel:
95
107
  * **Status Filter Tabs**: toggle between **All**, **Active**, **Paused**, and **Completed** tasks.
@@ -625,7 +637,8 @@ Connect to `GET /dsh-cron/tasks/:id/stream` using Server-Sent Events (SSE) to re
625
637
 
626
638
  Execute tasks instantaneously on external events via incoming HTTP POST requests with enterprise-grade security and dynamic template interpolation:
627
639
 
628
- - **Authentication & Verification**: Supports HMAC SHA-256 signatures (`x-hub-signature-256` or `x-signature-sha256`) using task-level `webhookSecret`, bearer tokens (`x-dsh-cron-webhook-token`, `Authorization: Bearer <token>`, or `?token=<token>`) against task `webhookToken` or master `apiToken`. Fail-closed security rejects unconfigured webhooks.
640
+ - **Authentication & Verification**: Supports HMAC SHA-256 signatures (`x-hub-signature-256` or `x-signature-sha256`) using task-level `webhookSecret`, bearer tokens passed via headers (`x-dsh-cron-webhook-token`, `x-webhook-token`, `Authorization: Bearer <token>`) against task `webhookToken` or master `apiToken`. Query parameter token authentication (`?token=`) is strictly rejected to prevent secret leakage in proxy logs and browser history (#358). Fail-closed security rejects unconfigured webhooks.
641
+ - **Fail-Closed API Authentication**: All REST routes verify callers via DSH `ctx.connection.requestRejection(req)`, token verification, and loopback verification. Requests without socket/remoteAddress and without valid authentication are strictly rejected with 401/403, preventing remote unauthenticated state modification and RCE (#357).
629
642
  - **Deep JSON Payload Interpolation**: Dynamic prompt variables resolve incoming payload fields using dot notation (`{webhook.body.repository.full_name}`, `{webhook.headers.x_github_event}`, `{webhook.query.ref}`, `{webhook.body}`).
630
643
  - **Script Environment Injection**: Script and shell tasks automatically receive the raw request body and headers in `DSH_CRON_WEBHOOK_BODY` and `DSH_CRON_WEBHOOK_HEADERS`.
631
644
  - **Anti-Spam Debouncing**: Configurable per-task `webhookDebounceMs` (default 500ms) guarantees protection against webhook storms and burst duplicate executions.
package/README.ru.md CHANGED
@@ -90,6 +90,18 @@ graph TD
90
90
 
91
91
  ## ✨ Возможности
92
92
 
93
+ ### 🔀 Решающие узлы и условные развилки IF/ELSE (#352)
94
+ - **Маршрутизация по условию**: Задачи могут задавать `branchRules` для проверки результата (`exitCode`, `output`, `output.json.<path>`, `status`) через операторы (`equals`, `notequals`, `contains`, `gt`, `gte`, `lt`, `lte`, `regex`, `exists`).
95
+ - **Ветка Else**: Резервная задача `elseTask` запускается, если ни одно из правил не совпало.
96
+ - **Защита от взаимных блокировок**: Невыбранные ветви автоматически регистрируются со статусом `skipped`, предотвращая зависание барьеров синхронизации (`joinPolicy: 'all_success'`).
97
+ - **Визуализация в графе**: DAG Graph View отображает бейджи решающих узлов (`🔀 Decision`) и условия на соединительных линиях.
98
+
99
+ ### 🩺 Автономный Доктор самовосстановления (Self-Healing Doctor) (#353)
100
+ - **Классификация сбоев**: Автоматическое распознавание 8 типов ошибок (`syntax_error`, `dependency_missing`, `rate_limit`, `auth_failed`, `schema_change`, `timeout`, `network_error`, `runtime_error`).
101
+ - **Unified Diff**: Наглядный просмотр диффа предлагаемого исправления до его применения.
102
+ - **1-Click применение и откат**: API эндпоинты `/doctor/apply` и `/doctor/revert` с сохранением до 10 ревизий в `task.revisions`.
103
+ - **Безопасная авто-починка**: При включённом `autoHeal: true` патчи с высокой уверенностью (`high`) применяются автоматически с лимитом не чаще 1 раза в сутки.
104
+
93
105
  ### 1. Визуальный менеджер задач
94
106
  Нажмите на иконку-часы в сайдбаре DSH (рядом с кнопкой новой сессии), чтобы открыть панель:
95
107
  * **Табы фильтрации**: **Все**, **Активные**, **На паузе**, **Завершённые**.
@@ -577,7 +589,8 @@ bash deploy.sh verify [exact-version]
577
589
  ### 32. Входящие HTTP Webhook триггеры и естественно-языковой парсер расписаний (v0.3.11, #348, #349)
578
590
 
579
591
  - **Входящие Webhook триггеры (`/dsh-cron/webhook/:id`)**: Моментальный запуск задач по внешним событиям (GitHub, Gitea, CI/CD, сторонние сервисы).
580
- - **Аутентификация и безопасность**: Поддержка HMAC SHA-256 подписи (`x-hub-signature-256`, `x-signature-sha256`) через `webhookSecret`, индивидуальных токенов задачи (`webhookToken`) через `x-dsh-cron-webhook-token`, Bearer или query-параметр, а также мастер-токена `apiToken`. Fail-closed архитектура отклоняет запросы к незащищенным задачам.
592
+ - **Аутентификация и безопасность**: Поддержка HMAC SHA-256 подписи (`x-hub-signature-256`, `x-signature-sha256`) через `webhookSecret`, индивидуальных токенов задачи (`webhookToken`) исключительно через заголовки (`x-dsh-cron-webhook-token`, `x-webhook-token`, `Authorization: Bearer`), а также мастер-токена `apiToken`. Приём токенов из query-параметров (`?token=`) строго заблокирован для предотвращения утечки в логи прокси и историю браузера (#358).
593
+ - **Fail-closed защита API и RCE Guard (#357)**: Все маршруты плагина проверяются через DSH `ctx.connection.requestRejection(req)` для допуска браузерных сессий DSH, а также требуют валидный Bearer-токен или локальный loopback адрес. Запросы без валидной аутентификации отсекаются с 401/403 до парсинга тела, предотвращая несанкционированное создание задач и удалённое выполнение кода.
581
594
  - **Глубокая интерполяция payload**: Шаблоны `{webhook.body.path.to.key}`, `{webhook.headers.key}`, `{webhook.query.param}` и `{webhook.body}` автоматически раскрываются в промпте задачи.
582
595
  - **Проброс в окружение**: Скриптовым и shell-задачам передаются переменные `DSH_CRON_WEBHOOK_BODY` и `DSH_CRON_WEBHOOK_HEADERS`.
583
596
  - **Дебаунс от штормов**: Настраиваемый `webhookDebounceMs` (по умолчанию 500 мс) защищает от дублирующих всплесков событий.
package/README.zh.md CHANGED
@@ -90,6 +90,18 @@ graph TD
90
90
 
91
91
  ## ✨ 功能与能力
92
92
 
93
+ ### 🔀 决策节点与条件分支 IF/ELSE (#352)
94
+ - **条件路由**:任务可配置 `branchRules`(评估 `exitCode`、`output`、`output.json.<path>` 等字段),支持 `equals`、`notequals`、`contains`、`gt`、`lt`、`regex` 等操作符。
95
+ - **回退分支**:若无匹配规则,自动执行 `elseTask`。
96
+ - **死锁防护**:未触发的分支自动标记为 `skipped`,确保后续汇聚栅栏(`joinPolicy: 'all_success'`)正常通过。
97
+ - **DAG 可视化**:图谱界面直观展示决策标识(`🔀 Decision`)与连线判断条件。
98
+
99
+ ### 🩺 自愈医生(Self-Healing Doctor)(#353)
100
+ - **故障诊断分类**:自动分类 8 类运行故障(语法错误、缺少依赖、频率限制、认证失效、架构变更、超时、网络错误、运行时异常)。
101
+ - **统一代码差分**:生成直观的标准 Unified Diff 对比。
102
+ - **一键应用与回滚**:提供 `/doctor/apply` 与 `/doctor/revert` 接口,自动保留多达 10 个历史版本(`task.revisions`)。
103
+ - **受控自主修复**:启用 `autoHeal: true` 时,高可信度(`high`)修复自动生效,且强制 24 小时频次限制防止死循环。
104
+
93
105
  ### 1. 可视化任务管理器
94
106
  点击 DSH 侧边栏中的时钟图标(位于“新会话”按钮旁)打开管理面板:
95
107
  * **状态过滤标签**:**全部**、**活跃**、**已暂停**、**已完成**。
@@ -574,6 +586,17 @@ dsh-cron:
574
586
 
575
587
  ---
576
588
 
589
+ ### 32. 入站 HTTP Webhook 与接口鉴权加固 (v0.3.12, #348, #357, #358)
590
+
591
+ - **入站 HTTP Webhook 触发器 (`/dsh-cron/webhook/:id`)**:外部事件(GitHub、Gitea、CI/CD 及第三方服务)实时触发任务执行。
592
+ - **安全鉴权**:支持基于任务级 `webhookSecret` 的 HMAC SHA-256 签名(`x-hub-signature-256`、`x-signature-sha256`)、任务级 `webhookToken`(仅限标头:`x-dsh-cron-webhook-token`、`x-webhook-token`、`Authorization: Bearer`)以及全局 `apiToken` 鉴权。严格禁止从 URL 查询参数(`?token=`)传递令牌以防止日志泄露(#358)。
593
+ - **深度 JSON 负载插值**:提示词中通过 `{webhook.body.path.to.key}`、`{webhook.headers.key}`、`{webhook.query.param}` 及 `{webhook.body}` 自动解构提取入站数据。
594
+ - **环境注入**:脚本与 shell 任务自动注入 `DSH_CRON_WEBHOOK_BODY` 和 `DSH_CRON_WEBHOOK_HEADERS` 环境变量。
595
+ - **防抖保护**:可配置 `webhookDebounceMs`(默认 500ms)有效防止 Webhook 事件风暴与瞬态重复触发。
596
+ - **Fail-Closed 接口鉴权与 RCE 防御(#357)**:所有 REST 端点通过 DSH `ctx.connection.requestRejection(req)` 校验会话权限,并对无有效令牌及非本地回环地址的请求实行 Fail-closed 严格拦截(401/403),杜绝未授权远程任务创建与代码执行。
597
+
598
+ ---
599
+
577
600
  ## 🧪 测试与预检门禁 (Preflight)
578
601
 
579
602
  运行全套自动化测试(调度表达式解析、调度器引擎、原子存储、HTTP 辅助函数、通知与工具契约):
@@ -4,13 +4,13 @@ import { sendJson, readBody, rejectCrossOrigin } from './http-utils.js';
4
4
 
5
5
  const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
6
6
 
7
- export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken }) {
7
+ export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken, ctx }) {
8
8
  if (req.method !== 'POST') {
9
9
  sendJson(res, 405, NOT_ALLOWED);
10
10
  return;
11
11
  }
12
12
  const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
13
- if (rejectCrossOrigin(req, res, { apiToken })) return;
13
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
14
14
 
15
15
  const task = store.get(taskId);
16
16
  if (!task) {
@@ -25,9 +25,9 @@ export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explici
25
25
  sendJson(res, 200, result);
26
26
  }
27
27
 
28
- export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken }) {
28
+ export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken, ctx }) {
29
29
  const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
30
- if (rejectCrossOrigin(req, res, { apiToken })) return;
30
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
31
31
 
32
32
  let schedule = '';
33
33
  let timezone = '';
@@ -61,9 +61,9 @@ export async function handleSchedulePreview({ req, res, url, store, apiToken: ex
61
61
  }
62
62
  }
63
63
 
64
- export async function handleDryRunTask({ store, scheduler, req, res, id }) {
65
- const apiToken = store?.getSettings?.()?.apiToken;
66
- if (rejectCrossOrigin(req, res, { apiToken })) return;
64
+ export async function handleDryRunTask({ store, scheduler, req, res, id, apiToken: explicitToken, ctx }) {
65
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
66
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
67
67
  const task = store.get(id);
68
68
  if (!task) {
69
69
  sendJson(res, 404, { ok: false, error: 'Task not found' });
@@ -78,9 +78,9 @@ export async function handleDryRunTask({ store, scheduler, req, res, id }) {
78
78
  }
79
79
  }
80
80
 
81
- export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken }) {
81
+ export async function handleScheduleTimeline({ req, res, url, store, scheduler, apiToken: explicitToken, ctx }) {
82
82
  const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
83
- if (rejectCrossOrigin(req, res, { apiToken })) return;
83
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
84
84
 
85
85
  if (req.method !== 'GET') {
86
86
  sendJson(res, 405, NOT_ALLOWED);
@@ -38,19 +38,19 @@ export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
38
38
  }
39
39
 
40
40
  /**
41
- * Verify token from header, Bearer auth, or query param.
41
+ * Verify token from header or Bearer auth.
42
+ * Query parameter authentication is strictly rejected to prevent token leakage in proxy logs and browser history (#358).
42
43
  */
43
44
  export function verifyToken(expectedToken, req, url) {
44
45
  if (!expectedToken) return false;
45
- const headers = req.headers || {};
46
+ const headers = req?.headers || {};
46
47
  const headerToken = headers['x-dsh-cron-webhook-token'] || headers['x-webhook-token'];
47
48
  const authHeader = headers['authorization'];
48
49
  let bearerToken = '';
49
50
  if (authHeader && /^Bearer\s+/i.test(authHeader)) {
50
51
  bearerToken = authHeader.replace(/^Bearer\s+/i, '').trim();
51
52
  }
52
- const queryToken = url?.searchParams?.get('token');
53
- const candidate = headerToken || bearerToken || queryToken;
53
+ const candidate = headerToken || bearerToken;
54
54
  if (!candidate) return false;
55
55
  return safeCompare(expectedToken, candidate);
56
56
  }
package/lib/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { applyDoctorFix, revertDoctorFix, diagnoseFailure } from './doctor.js';
1
2
  import { handleTaskWebhookRoute } from './api-task-webhook.js';
2
3
  import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
3
4
  import { sanitizeAdaptiveConfig } from './adaptive.js';
@@ -71,14 +72,17 @@ function refuseConfigOwned(res, id) {
71
72
  * deeper path unrouted (#96). Exported for unit tests.
72
73
  */
73
74
  export function createCronApiHandler(store, scheduler, collection) {
75
+ const ctx = collection?.ctx;
74
76
  return async function handleCronApi(req, res) {
75
77
  try {
76
78
  const apiToken = store?.getSettings?.()?.apiToken;
79
+ const authOptions = { apiToken, ctx };
77
80
  const url = new URL(req.url, 'http://127.0.0.1');
78
81
  const parts = url.pathname.split('/').filter(Boolean);
79
82
  const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
80
83
  const id = parts[2];
81
84
  const action = parts[3];
85
+ const subAction = parts[4];
82
86
 
83
87
  if (scope === 'telegram' && id === 'webhook') {
84
88
  await handleTelegramWebhook({ store, scheduler, req, res });
@@ -90,25 +94,25 @@ export function createCronApiHandler(store, scheduler, collection) {
90
94
  }
91
95
 
92
96
  if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
93
- handleHeartbeatPing({ store, req, res, taskId: id });
97
+ handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
94
98
  return;
95
99
  }
96
100
 
97
101
  if (scope === 'schedule' && id === 'preview') {
98
- await handleSchedulePreview({ req, res, url, store });
102
+ await handleSchedulePreview({ req, res, url, store, apiToken, ctx });
99
103
  return;
100
104
  }
101
105
  if (scope === 'schedule' && id === 'timeline-24h') {
102
- await handleScheduleTimeline({ req, res, url, store, scheduler });
106
+ await handleScheduleTimeline({ req, res, url, store, scheduler, apiToken, ctx });
103
107
  return;
104
108
  }
105
109
 
106
110
  if (!id) {
107
- await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken });
111
+ await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx });
108
112
  return;
109
113
  }
110
114
 
111
- if (rejectCrossOrigin(req, res, { apiToken })) return;
115
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
112
116
 
113
117
  // Export/import are collection operations that live under /tasks/:id/…,
114
118
  // so they are matched before the generic id handling below.
@@ -129,7 +133,7 @@ export function createCronApiHandler(store, scheduler, collection) {
129
133
  return;
130
134
  }
131
135
 
132
- await handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken });
136
+ await handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx });
133
137
  } catch (err) {
134
138
  sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
135
139
  }
@@ -138,19 +142,19 @@ export function createCronApiHandler(store, scheduler, collection) {
138
142
 
139
143
  // ------------------------------------------------------------- collection
140
144
 
141
- async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken }) {
145
+ async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx }) {
142
146
  // The alias family only exists for individual tasks.
143
147
  if (scope === 'action' || !collection) {
144
148
  sendJson(res, 405, NOT_ALLOWED);
145
149
  return;
146
150
  }
147
151
  if (req.method === 'GET') {
148
- if (rejectCrossOrigin(req, res, { apiToken })) return;
152
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
149
153
  listTasks({ store, scheduler, collection, req, res, url });
150
154
  return;
151
155
  }
152
156
  if (req.method === 'POST') {
153
- await createOrUpdateTask({ store, scheduler, req, res, apiToken });
157
+ await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
154
158
  return;
155
159
  }
156
160
  sendJson(res, 405, NOT_ALLOWED);
@@ -187,8 +191,8 @@ function listTasks({ store, scheduler, collection, req, res, url }) {
187
191
  sendJson(res, 200, payload);
188
192
  }
189
193
 
190
- async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
191
- if (rejectCrossOrigin(req, res, { apiToken })) return;
194
+ async function createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx }) {
195
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
192
196
  const { body: rawBody, error } = await readBody(req, res);
193
197
  if (error) return;
194
198
  const existing = rawBody.id ? store.get(rawBody.id) : null;
@@ -291,14 +295,14 @@ function buildTaskRecord(store, body, taskType, parsed) {
291
295
 
292
296
  // ------------------------------------------------------------ single task
293
297
 
294
- async function handleTaskItem({ store, scheduler, req, res, url, id, action, apiToken }) {
298
+ async function handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx }) {
295
299
  if (action === 'heartbeat') {
296
- handleHeartbeatPing({ store, req, res, taskId: id });
300
+ handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
297
301
  return;
298
302
  }
299
303
 
300
304
  if (req.method === 'POST' && action === 'dry-run') {
301
- await handleDryRunTask({ store, scheduler, req, res, id });
305
+ await handleDryRunTask({ store, scheduler, req, res, id, apiToken, ctx });
302
306
  return;
303
307
  }
304
308
 
@@ -307,7 +311,7 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
307
311
  logStreamer.handleSseRequest(id, req, res);
308
312
  return;
309
313
  }
310
- if (rejectCrossOrigin(req, res, { apiToken })) return;
314
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
311
315
  if (action === 'archive') {
312
316
  const limit = parseInt(url.searchParams.get('limit') || '50', 10);
313
317
  const offset = parseInt(url.searchParams.get('offset') || '0', 10);
@@ -342,6 +346,25 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
342
346
  sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
343
347
  return;
344
348
  }
349
+ if (action === 'doctor') {
350
+ const task = store.get(id);
351
+ if (!task) {
352
+ sendJson(res, 404, NOT_FOUND);
353
+ return;
354
+ }
355
+ const runs = store.getHistory(id, 5);
356
+ const latestRun = runs[0] || null;
357
+ const reportRun = runs.find((r) => r.doctorReport) || latestRun;
358
+ sendJson(res, 200, {
359
+ ok: true,
360
+ taskId: id,
361
+ doctorReport: reportRun?.doctorReport || null,
362
+ revisions: task.revisions || [],
363
+ previousPrompt: task.previousPrompt || null,
364
+ autoHeal: Boolean(task.autoHeal || task.selfHealing?.autoApply),
365
+ });
366
+ return;
367
+ }
345
368
  if (!action) {
346
369
  const task = store.get(id);
347
370
  if (!task) {
@@ -360,7 +383,7 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
360
383
  }
361
384
  }
362
385
  if (req.method === 'POST') {
363
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
386
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
364
387
  if (handled) return;
365
388
  }
366
389
  if (req.method === 'PATCH') {
@@ -375,7 +398,97 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
375
398
  }
376
399
 
377
400
  /** Task actions that are plain POSTs. Returns true when one matched. */
378
- async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
401
+ async function handleItemPost({ store, scheduler, req, res, url, id, action, subAction }) {
402
+ if ((action === 'doctor' && subAction === 'apply') || action === 'doctor-apply') {
403
+ const task = store.get(id);
404
+ if (!task) {
405
+ sendJson(res, 404, NOT_FOUND);
406
+ return true;
407
+ }
408
+ const body = await readBody(req, res);
409
+ const bodyData = (body && body.body) || {};
410
+ let fix = bodyData.fix || bodyData.doctorReport;
411
+ if (!fix) {
412
+ const runs = store.getHistory(id, 5);
413
+ const reportRun = runs.find((r) => r.doctorReport);
414
+ fix = reportRun?.doctorReport;
415
+ }
416
+ if (!fix || !fix.suggestedFix) {
417
+ sendJson(res, 400, { ok: false, error: 'No doctor fix found to apply' });
418
+ return true;
419
+ }
420
+ try {
421
+ const result = await applyDoctorFix(task, fix, { store, user: bodyData.user || 'operator' });
422
+ if (!result.ok) {
423
+ sendJson(res, 400, { ok: false, error: result.error });
424
+ return true;
425
+ }
426
+ sendJson(res, 200, {
427
+ ok: true,
428
+ taskId: id,
429
+ task: redactTaskSecrets(task),
430
+ revision: result.revision,
431
+ });
432
+ } catch (err) {
433
+ sendJson(res, 500, { ok: false, error: err.message });
434
+ }
435
+ return true;
436
+ }
437
+
438
+ if ((action === 'doctor' && subAction === 'revert') || action === 'doctor-revert') {
439
+ const task = store.get(id);
440
+ if (!task) {
441
+ sendJson(res, 404, NOT_FOUND);
442
+ return true;
443
+ }
444
+ const body = await readBody(req, res);
445
+ const bodyData = (body && body.body) || {};
446
+ try {
447
+ const result = await revertDoctorFix(task, { store, user: bodyData.user || 'operator' });
448
+ if (!result.ok) {
449
+ sendJson(res, 400, { ok: false, error: result.error });
450
+ return true;
451
+ }
452
+ sendJson(res, 200, {
453
+ ok: true,
454
+ taskId: id,
455
+ task: redactTaskSecrets(task),
456
+ revertedCode: result.revertedCode,
457
+ });
458
+ } catch (err) {
459
+ sendJson(res, 500, { ok: false, error: err.message });
460
+ }
461
+ return true;
462
+ }
463
+
464
+ if ((action === 'doctor' && subAction === 'diagnose') || action === 'doctor-diagnose') {
465
+ const task = store.get(id);
466
+ if (!task) {
467
+ sendJson(res, 404, NOT_FOUND);
468
+ return true;
469
+ }
470
+ const body = await readBody(req, res);
471
+ const bodyData = (body && body.body) || {};
472
+ const runId = bodyData.runId;
473
+ const runs = store.getHistory(id, 10);
474
+ const targetRun = runId ? runs.find((r) => r.id === runId) : runs[0];
475
+ if (!targetRun) {
476
+ sendJson(res, 404, { ok: false, error: 'No run history found to diagnose' });
477
+ return true;
478
+ }
479
+ try {
480
+ const doctorReport = await diagnoseFailure({
481
+ task,
482
+ runInfo: targetRun,
483
+ ask: scheduler?.askModel,
484
+ });
485
+ targetRun.doctorReport = doctorReport;
486
+ sendJson(res, 200, { ok: true, taskId: id, doctorReport });
487
+ } catch (err) {
488
+ sendJson(res, 500, { ok: false, error: err.message });
489
+ }
490
+ return true;
491
+ }
379
492
  if (action === 'prompt-rollback') {
380
493
  const task = store.get(id);
381
494
  if (!task) {
@@ -696,6 +809,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
696
809
  }
697
810
  const id = parts[3];
698
811
  const action = parts[4];
812
+ const subAction = parts[5];
699
813
 
700
814
  if (!id) {
701
815
  if (req.method === 'GET') {
@@ -706,7 +820,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
706
820
  return;
707
821
  }
708
822
  if (req.method === 'POST') {
709
- await createOrUpdateTask({ store, scheduler, req, res, apiToken });
823
+ await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
710
824
  return;
711
825
  }
712
826
  sendJson(res, 405, NOT_ALLOWED);
@@ -714,7 +828,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
714
828
  }
715
829
 
716
830
  if (req.method === 'POST') {
717
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
831
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
718
832
  if (handled) return;
719
833
  }
720
834
  if (req.method === 'GET' && !action) {