@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 +14 -1
- package/README.ru.md +14 -1
- package/README.zh.md +23 -0
- package/lib/api-helpers.js +9 -9
- package/lib/api-task-webhook.js +4 -4
- package/lib/api.js +133 -19
- package/lib/branching.js +341 -0
- package/lib/client.js +870 -758
- package/lib/dag.js +88 -18
- package/lib/doctor.js +431 -0
- package/lib/http-utils.js +116 -35
- package/lib/index.js +2 -2
- package/lib/routes.js +19 -19
- package/lib/scheduler-execution.js +59 -6
- package/lib/task-create.js +9 -0
- package/lib/task-patch.js +18 -1
- package/lib/task-transfer.js +7 -0
- package/package.json +1 -1
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
|
|
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`,
|
|
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 辅助函数、通知与工具契约):
|
package/lib/api-helpers.js
CHANGED
|
@@ -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);
|
package/lib/api-task-webhook.js
CHANGED
|
@@ -38,19 +38,19 @@ export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Verify token from header
|
|
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
|
|
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
|
|
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) {
|