@goodandready/dsh-cron 0.3.14 → 0.3.16

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
@@ -205,7 +205,7 @@ Every task picks its own runtime; non-LLM runtimes need no model and consume no
205
205
  * **SSH** (`ssh`) — execute a command on a remote host through a `dsh-remote-workspace` profile (`sshProfileId`) or standalone host/key fields.
206
206
  * **Docker** (`docker`) — run the command in a container image (`dockerImage`).
207
207
  * **Environment variables** — a per-task `env` map (KEY VALUE per line in the UI) applied to external runtimes; secrets do not belong here.
208
- * **Workspaces and worktrees** — bind a task to a harness workspace (`workspaceId`) and, for code-modifying agent tasks, run it in an isolated git worktree (`worktree`, `keepWorktree`).
208
+ * **Workspaces and worktrees** — bind a task to a harness workspace (`workspaceId`, resolved through host `workspaceRegistry`) or path, and run code-modifying agent tasks in an isolated git worktree (`worktree`, `keepWorktree`).
209
209
 
210
210
  ### 7. Cost Control: Fallback Model & Burn Guard
211
211
  * **Fallback Model** — A task can run on the cheap model by default and still finish on the strong one: set `fallbackModel` (and optionally `fallbackProvider`) and a failed run — `error` or `timeout` — is retried **once** on that model before the ordinary retry backoff applies. History records which model produced the result and whether the fallback was used, usage and cost of both attempts are summed, and the `{model}` template variable renders the model that finished the run. Only agent-mediated tasks (`llm`, `skill`, `workflow`) can use a fallback.
package/README.ru.md CHANGED
@@ -205,7 +205,7 @@ cron({
205
205
  * **SSH** (`ssh`) — выполнение команды на удалённом хосте через профиль `dsh-remote-workspace` (`sshProfileId`) или отдельные поля host/key.
206
206
  * **Docker** (`docker`) — выполнение команды в контейнере образа (`dockerImage`).
207
207
  * **Переменные окружения** — карта `env` на задачу (в UI — строки KEY VALUE) для внешних рантаймов; секретам здесь не место.
208
- * **Workspace и worktree** — привязка задачи к workspace харнесса (`workspaceId`) и, для изменяющих код агентских задач, запуск в изолированном git worktree (`worktree`, `keepWorktree`).
208
+ * **Workspace и worktree** — привязка задачи к workspace харнесса (`workspaceId`, разрешаемому через службу `workspaceRegistry`) или пути, и запуск в изолированном git worktree (`worktree`, `keepWorktree`).
209
209
 
210
210
  ### 7. Экономия: fallback-модель и защита бюджета (Burn Guard)
211
211
  * **Fallback-модель** — задача может идти на дешёвой модели по умолчанию и всё же завершиться на сильной: задайте `fallbackModel` (и при необходимости `fallbackProvider`), и сбойный запуск (`error` или `timeout`) один раз повторится на этой модели, прежде чем включится обычный retry с задержкой. В истории видно, какая модель произвела результат и был ли использован fallback; расход и стоимость обеих попыток суммируются; переменная шаблона `{model}` подставляет модель, завершившую запуск. Fallback доступен только агентским типам (`llm`, `skill`, `workflow`).
package/README.zh.md CHANGED
@@ -205,7 +205,7 @@ cron({
205
205
  * **SSH**(`ssh`)—— 通过 `dsh-remote-workspace` 配置(`sshProfileId`)或独立 host/key 字段在远程主机执行命令。
206
206
  * **Docker**(`docker`)—— 在镜像容器(`dockerImage`)中执行命令。
207
207
  * **环境变量** —— 按任务的 `env` 映射(界面中每行 KEY VALUE)应用于外部运行时;请勿在此存放密钥。
208
- * **工作区与 worktree** —— 将任务绑定到 Harness 工作区(`workspaceId`);对会修改代码的智能体任务,可在隔离的 git worktree 中运行(`worktree`、`keepWorktree`)。
208
+ * **工作区与 worktree** —— 将任务绑定到 Harness 工作区(`workspaceId`,通过宿主 `workspaceRegistry` 服务解析)或直接路径;对会修改代码的智能体任务,可在隔离的 git worktree 中运行(`worktree`、`keepWorktree`)。
209
209
 
210
210
  ### 7. 成本控制:回退模型与支出保护(Burn Guard)
211
211
  * **回退模型** —— 任务可以默认使用便宜模型,失败时改用更强模型完成:设置 `fallbackModel`(可选 `fallbackProvider`),失败(`error` 或 `timeout`)的运行会在该模型上重试一次,之后才进入常规重试退避。历史记录会标明最终产出结果的模型以及是否使用了回退,两次尝试的用量与成本都会累计,模板变量 `{model}` 渲染完成运行的模型。回退仅适用于智能体类型(`llm`、`skill`、`workflow`)。
@@ -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
+
@@ -0,0 +1,249 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseScheduleExpression } from './scheduler.js';
3
+ import { normalizeTaskType, CODE_EXECUTING_TYPES, EXTERNAL_RUNTIMES } from './runtimes.js';
4
+ import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
5
+ import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
6
+ import { sendJson, rejectCrossOrigin, readBody, SCRIPT_CONFIRM_HEADER, redactTaskSecrets, restoreTaskSecrets } from './http-utils.js';
7
+ import {
8
+ validateTaskType,
9
+ RESERVED_TASK_IDS,
10
+ isCodeExecutingTask,
11
+ } from './task-transfer.js';
12
+ import { sanitizeAdaptiveConfig } from './adaptive.js';
13
+ import { sanitizeValidators } from './validators.js';
14
+
15
+ export const NOT_FOUND = { ok: false, error: 'Task not found' };
16
+ export const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
17
+
18
+ /**
19
+ * A task declared in the profile config is owned by that file (#50): the next
20
+ * plugin start would revert any change made here, so every mutating route
21
+ * refuses it and says where the task really lives.
22
+ */
23
+ export function refuseConfigOwned(res, id) {
24
+ sendJson(res, 409, { ok: false, error: configOwnedMessage(id) });
25
+ }
26
+
27
+ // ------------------------------------------------------------- collection
28
+
29
+ export async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx }) {
30
+ // The alias family only exists for individual tasks.
31
+ if (scope === 'action' || !collection) {
32
+ sendJson(res, 405, NOT_ALLOWED);
33
+ return;
34
+ }
35
+ if (req.method === 'GET') {
36
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
37
+ listTasks({ store, scheduler, collection, req, res, url });
38
+ return;
39
+ }
40
+ if (req.method === 'POST') {
41
+ await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
42
+ return;
43
+ }
44
+ sendJson(res, 405, NOT_ALLOWED);
45
+ }
46
+
47
+ export function listTasks({ store, scheduler, collection, req, res, url }) {
48
+ const status = url.searchParams.get('status') || 'all';
49
+ const query = url.searchParams.get('query') || '';
50
+ const list = store.list({ status, query }).map((t) => ({
51
+ ...redactTaskSecrets(t),
52
+ running: scheduler.isRunning(t.id),
53
+ runningSince: scheduler.runningSince(t.id),
54
+ }));
55
+ const stats = store.getAggregatedStats();
56
+ const payload = { ok: true, tasks: list, recommendations: collection.recommendations, stats };
57
+
58
+ // Calculate ETag based on payload JSON representation (#134)
59
+ const bodyString = JSON.stringify(payload);
60
+ const etag = 'W/"' + createHash('sha1').update(bodyString).digest('hex') + '"';
61
+
62
+ if (req && req.headers) {
63
+ const ifNoneMatch = req.headers['if-none-match'];
64
+ if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === etag.slice(2))) {
65
+ res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-cache' });
66
+ res.end();
67
+ return;
68
+ }
69
+ }
70
+
71
+ if (typeof res.setHeader === 'function') {
72
+ res.setHeader('ETag', etag);
73
+ res.setHeader('Cache-Control', 'no-cache');
74
+ }
75
+ sendJson(res, 200, payload);
76
+ }
77
+
78
+ export async function createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx }) {
79
+ if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
80
+ const { body: rawBody, error } = await readBody(req, res);
81
+ if (error) return;
82
+ const existing = rawBody.id ? store.get(rawBody.id) : null;
83
+ const body = existing ? restoreTaskSecrets(rawBody, existing) : rawBody;
84
+ if (!body.title || !body.schedule || !body.prompt) {
85
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
86
+ return;
87
+ }
88
+ const unknownChannels = unknownChannelIds(body.channels);
89
+ if (unknownChannels.length) {
90
+ sendJson(res, 400, {
91
+ ok: false,
92
+ error: 'Unknown channel ids: ' + unknownChannels.join(', '),
93
+ unknownChannels,
94
+ });
95
+ return;
96
+ }
97
+ const taskType = normalizeTaskType(body.type);
98
+ if ((CODE_EXECUTING_TYPES.includes(taskType) || isCodeExecutingTask(body)) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
99
+ sendJson(res, 403, { ok: false, error: `Creating code-executing tasks over HTTP requires the x-dsh-cron-confirm: script header` });
100
+ return;
101
+ }
102
+ const typeError = validateTaskType(taskType, body);
103
+ if (typeError) {
104
+ sendJson(res, 400, { ok: false, error: typeError });
105
+ return;
106
+ }
107
+ let parsed;
108
+ try {
109
+ parsed = parseScheduleExpression(body.schedule);
110
+ } catch (err) {
111
+ sendJson(res, 400, { ok: false, error: err.message });
112
+ return;
113
+ }
114
+ // A client-supplied id must not shadow the collection routes under
115
+ // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
116
+ // again (#42 review finding).
117
+ if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
118
+ sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
119
+ return;
120
+ }
121
+ // Create-or-update with an existing id is an update too, so the config-owned
122
+ // refusal must apply here as well — otherwise the POST route would be a way
123
+ // around the guard on PATCH (#50 review finding).
124
+ if (body.id) {
125
+ const existing = store.get(body.id);
126
+ if (existing && existing.managedBy === MANAGED_BY_CONFIG) {
127
+ refuseConfigOwned(res, body.id);
128
+ return;
129
+ }
130
+ }
131
+ let task;
132
+ try {
133
+ task = store.set(buildTaskRecord(store, body, taskType, parsed));
134
+ } catch (err) {
135
+ sendJson(res, 500, { ok: false, error: 'Failed to persist task: ' + err.message });
136
+ return;
137
+ }
138
+ if (task.status === 'active') {
139
+ scheduler.scheduleTask(task);
140
+ } else {
141
+ scheduler.pauseTask(task.id);
142
+ }
143
+ sendJson(res, 200, { ok: true, task: redactTaskSecrets(task) });
144
+ }
145
+
146
+ /** Merge a create/update payload onto the stored task, if any. */
147
+ export function buildTaskRecord(store, body, taskType, parsed) {
148
+ const current = body.id ? store.get(body.id) : null;
149
+ const resolvedStatus = body.status || (current ? current.status : 'active');
150
+ return {
151
+ id: body.id,
152
+ title: body.title,
153
+ schedule: parsed.cronPattern || body.schedule,
154
+ scheduleText: body.scheduleText || parsed.humanText,
155
+ prompt: body.prompt,
156
+ type: taskType,
157
+ delivery: body.delivery || 'current',
158
+ status: resolvedStatus,
159
+ provider: body.provider || undefined,
160
+ model: body.model || undefined,
161
+ fallbackProvider: body.fallbackProvider || undefined,
162
+ fallbackModel: body.fallbackModel || undefined,
163
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : undefined),
164
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : undefined),
165
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
166
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
167
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
168
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
169
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
170
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
171
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
172
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
173
+ validators: Array.isArray(body.validators) ? sanitizeValidators(body.validators) : (current ? current.validators : undefined),
174
+ ...mergeExecutionFields(current, body, parsed),
175
+ };
176
+ }
177
+
178
+ // export/import logic moved to ./api-import-export.js
179
+
180
+ /**
181
+ * Runtime and execution settings of a task, merged onto the stored one. Split
182
+ * out of buildTaskRecord so both stay readable.
183
+ */
184
+ export function mergeExecutionFields(current, body, parsed) {
185
+ return {
186
+ env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
187
+ cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
188
+ channels: Array.isArray(body.channels)
189
+ ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
190
+ : (current ? current.channels : undefined),
191
+ template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
192
+ silentRule: body.silentRule !== undefined ? String(body.silentRule) : (current ? current.silentRule : ''),
193
+ inspectOnFailure: body.inspectOnFailure !== undefined ? Boolean(body.inspectOnFailure) : (current ? Boolean(current.inspectOnFailure) : false),
194
+ workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
195
+ worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
196
+ keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
197
+ httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
198
+ httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
199
+ httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
200
+ httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
201
+ sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
202
+ sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
203
+ sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
204
+ sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
205
+ dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
206
+ pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
207
+ nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
208
+ skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
209
+ workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
210
+ oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
211
+ targetTimestamp: parsed && parsed.isOneShot
212
+ ? (current && current.schedule === (parsed.cronPattern || body.schedule) ? (current.targetTimestamp || parsed.targetTimestamp) : parsed.targetTimestamp)
213
+ : (current ? current.targetTimestamp : undefined),
214
+ costLimitUsd: body.costLimitUsd !== undefined ? (body.costLimitUsd === null ? null : (Number(body.costLimitUsd) > 0 ? Number(body.costLimitUsd) : null)) : (current ? current.costLimitUsd : undefined),
215
+ dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
216
+ tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
217
+ pausedReason: body.pausedReason !== undefined ? (body.pausedReason ? String(body.pausedReason) : null) : (current ? current.pausedReason : undefined),
218
+ agentPreset: body.agentPreset !== undefined ? String(body.agentPreset).trim() : (current ? (current.agentPreset || '') : ''),
219
+ targetSessionId: body.targetSessionId !== undefined ? String(body.targetSessionId).trim() : (current ? (current.targetSessionId || '') : ''),
220
+ targetSessionReset: body.targetSessionReset !== undefined ? String(body.targetSessionReset).trim() : (current ? (current.targetSessionReset || 'never') : 'never'),
221
+ sessionTitle: body.sessionTitle !== undefined ? String(body.sessionTitle).trim() : (current ? (current.sessionTitle || '') : ''),
222
+ webhookEnabled: body.webhookEnabled !== undefined
223
+ ? Boolean(body.webhookEnabled)
224
+ : (body.webhookTrigger?.enabled !== undefined ? Boolean(body.webhookTrigger.enabled) : (current ? Boolean(current.webhookEnabled || current.webhookTrigger?.enabled) : false)),
225
+ webhookToken: body.webhookToken !== undefined
226
+ ? String(body.webhookToken).trim()
227
+ : (body.webhookTrigger?.token ? String(body.webhookTrigger.token).trim() : (current ? (current.webhookToken || current.webhookTrigger?.token || '') : '')),
228
+ webhookSecret: body.webhookSecret !== undefined
229
+ ? String(body.webhookSecret).trim()
230
+ : (body.webhookTrigger?.secret ? String(body.webhookTrigger.secret).trim() : (current ? (current.webhookSecret || current.webhookTrigger?.secret || '') : '')),
231
+ webhookDebounceMs: body.webhookDebounceMs !== undefined
232
+ ? (Number(body.webhookDebounceMs) || 0)
233
+ : (body.webhookTrigger?.debounceMs !== undefined ? (Number(body.webhookTrigger.debounceMs) || 0) : (current ? (Number(current.webhookDebounceMs || current.webhookTrigger?.debounceMs) || 0) : 0)),
234
+ onSuccess: body.onSuccess !== undefined ? String(body.onSuccess).trim() : (current ? (current.onSuccess || '') : ''),
235
+ onFailure: body.onFailure !== undefined ? String(body.onFailure).trim() : (current ? (current.onFailure || '') : ''),
236
+ heartbeatIntervalSeconds: body.heartbeatIntervalSeconds !== undefined ? Number(body.heartbeatIntervalSeconds) : (current ? (Number(current.heartbeatIntervalSeconds) || 0) : 0),
237
+ gracePeriodSeconds: body.gracePeriodSeconds !== undefined ? Number(body.gracePeriodSeconds) : (current ? (Number(current.gracePeriodSeconds) || 300) : 300),
238
+ preflightType: body.preflightType !== undefined ? String(body.preflightType).trim().toLowerCase() : (current ? (current.preflightType || 'none') : 'none'),
239
+ preflightTarget: body.preflightTarget !== undefined ? String(body.preflightTarget).trim() : (current ? (current.preflightTarget || '') : ''),
240
+ priority: body.priority !== undefined ? (Number(body.priority) || 5) : (current ? (Number(current.priority) || 5) : 5),
241
+ concurrencyGroup: body.concurrencyGroup !== undefined ? String(body.concurrencyGroup).trim() : (current ? (current.concurrencyGroup || 'default') : 'default'),
242
+ selfHealingCommand: body.selfHealingCommand !== undefined ? String(body.selfHealingCommand).trim() : (current ? (current.selfHealingCommand || '') : ''),
243
+ autoDiagnose: body.autoDiagnose !== undefined ? Boolean(body.autoDiagnose) : (current ? Boolean(current.autoDiagnose) : false),
244
+ adaptive: body.adaptive !== undefined ? sanitizeAdaptiveConfig(body.adaptive) : (current ? current.adaptive : undefined),
245
+ adaptiveState: current ? current.adaptiveState : undefined,
246
+ };
247
+ }
248
+
249
+