@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.
Files changed (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
@@ -0,0 +1,198 @@
1
+ import { bestEffort } from '../best-effort.js';
2
+ /**
3
+ * Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
4
+ *
5
+ * Every builder is a pure function returning the process invocation
6
+ * ({ file, args, env }) or the HTTP request description, so the whole matrix
7
+ * is unit-testable without spawning anything.
8
+ */
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
14
+ const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
15
+ export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
16
+
17
+ /** Types that execute code and therefore need the HTTP confirm header. */
18
+ export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
19
+
20
+ export function normalizeTaskType(type) {
21
+ return TASK_TYPES.includes(type) ? type : 'llm';
22
+ }
23
+
24
+ /**
25
+ * Split a leading script path from trailing CLI arguments.
26
+ * Quoted segments are preserved as single arguments.
27
+ */
28
+ export function splitCommandLine(text) {
29
+ const trimmed = String(text || '').trim();
30
+ if (!trimmed) return { path: '', args: [] };
31
+ const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
32
+ const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
33
+ return { path: clean[0] || '', args: clean.slice(1) };
34
+ }
35
+
36
+ function looksLikeScriptFile(token, extensions) {
37
+ if (!token) return false;
38
+ const ext = path.extname(token).toLowerCase();
39
+ if (extensions.includes(ext)) return true;
40
+ try {
41
+ return fs.existsSync(token) && fs.statSync(token).isFile();
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
48
+ export function buildNodeInvocation(task, cwd) {
49
+ const nodeExecutable = task.nodePath || process.execPath;
50
+ const { path: filePath, args } = splitCommandLine(task.prompt);
51
+ const flags = [];
52
+ if (Number(task.maxMemoryMb) > 0) {
53
+ flags.push(`--max-old-space-size=${Number(task.maxMemoryMb)}`);
54
+ }
55
+ if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
56
+ return { file: nodeExecutable, args: [...flags, filePath, ...args] };
57
+ }
58
+ const code = String(task.prompt || '');
59
+ const isEsm = /\bimport\s|\bexport\s/.test(code);
60
+ const nodeArgs = isEsm ? [...flags, '--input-type=module', '-e', code] : [...flags, '-e', code];
61
+ return { file: nodeExecutable, args: nodeArgs };
62
+ }
63
+
64
+ /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
65
+ function detectPython(task, cwd) {
66
+ if (task.pythonPath) return task.pythonPath;
67
+ const candidates = [];
68
+ if (process.env.VIRTUAL_ENV) {
69
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
70
+ candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
71
+ }
72
+ const base = cwd || process.cwd();
73
+ candidates.push(path.join(base, '.venv', 'bin', 'python'));
74
+ candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
75
+ candidates.push(path.join(base, 'venv', 'bin', 'python'));
76
+ for (const candidate of candidates) {
77
+ const exists = bestEffort('check-python-candidate', () => fs.existsSync(candidate));
78
+ if (exists) return candidate;
79
+ }
80
+ return process.platform === 'win32' ? 'python' : 'python3';
81
+ }
82
+
83
+ /** #4: resolve a Python invocation (inline code or a .py file). */
84
+ export function buildPythonInvocation(task, cwd) {
85
+ const interpreter = detectPython(task, cwd);
86
+ const { path: filePath, args } = splitCommandLine(task.prompt);
87
+ if (looksLikeScriptFile(filePath, ['.py'])) {
88
+ return { file: interpreter, args: [filePath, ...args] };
89
+ }
90
+ return { file: interpreter, args: ['-c', String(task.prompt || '')] };
91
+ }
92
+
93
+ /** #7: build the HTTP trigger request description. */
94
+ export function buildHttpRequest(task) {
95
+ const raw = String(task.httpUrl || task.prompt || '').trim();
96
+ if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
97
+ let url;
98
+ try {
99
+ url = new URL(raw);
100
+ } catch {
101
+ throw new Error(`Invalid HTTP URL: ${raw}`);
102
+ }
103
+ let headers = {};
104
+ if (task.httpHeaders) {
105
+ if (typeof task.httpHeaders === 'object') {
106
+ headers = { ...task.httpHeaders };
107
+ } else {
108
+ try {
109
+ headers = JSON.parse(String(task.httpHeaders));
110
+ } catch {
111
+ throw new Error('httpHeaders must be a JSON object');
112
+ }
113
+ }
114
+ }
115
+ const method = String(task.httpMethod || 'GET').toUpperCase();
116
+ const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
117
+ return { url: url.toString(), method, headers, body };
118
+ }
119
+
120
+ /** #9: build a remote SSH invocation. */
121
+ export function buildSshInvocation(task) {
122
+ const target = String(task.sshTarget || '').trim();
123
+ if (!target) throw new Error('SSH task requires sshTarget (user@host)');
124
+ const args = [];
125
+ if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
126
+ if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
127
+ args.push(target, String(task.prompt || 'true'));
128
+ return { file: 'ssh', args };
129
+ }
130
+
131
+ /** #8/#226: build Docker run invocation without leaking host process secrets into container. */
132
+ export function buildDockerInvocation(task, explicitEnv) {
133
+ const image = String(task.dockerImage || '').trim();
134
+ if (!image) throw new Error('Docker task requires dockerImage');
135
+ const args = ['run', '--rm'];
136
+
137
+ // Issue #226: isolate container environment. If explicitEnv is passed directly
138
+ // (e.g. test or explicit invocation), use it; otherwise use task.env only.
139
+ let envMap = explicitEnv;
140
+ if (!envMap) {
141
+ envMap = {};
142
+ if (task && task.env && typeof task.env === 'object') {
143
+ for (const [k, v] of Object.entries(task.env)) {
144
+ if (v !== null && v !== undefined) envMap[k] = String(v);
145
+ }
146
+ }
147
+ }
148
+
149
+ for (const [key, value] of Object.entries(envMap)) {
150
+ if (key === 'PATH' || key === 'HOME') continue; // container owns those
151
+ if (value === null || value === undefined) continue;
152
+ args.push('-e', `${key}=${value}`);
153
+ }
154
+ if (Number(task.maxMemoryMb) > 0) args.push(`--memory=${Number(task.maxMemoryMb)}m`);
155
+ if (Number(task.cpus) > 0) args.push(`--cpus=${Number(task.cpus)}`);
156
+ if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
157
+ args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
158
+ return { file: 'docker', args, env: envMap };
159
+ }
160
+
161
+ /** #38: merge task env vars over the base environment. */
162
+ export function buildEnv(task, baseEnv) {
163
+ const env = { ...(baseEnv || process.env) };
164
+ if (task && task.env && typeof task.env === 'object') {
165
+ for (const [key, value] of Object.entries(task.env)) {
166
+ if (value === null || value === undefined) delete env[key];
167
+ else env[key] = String(value);
168
+ }
169
+ }
170
+ return env;
171
+ }
172
+
173
+ /**
174
+ * Dispatch an external (non-LLM) runtime to its invocation.
175
+ * Returns { file, args } plus an optional `http` descriptor for the fetch path.
176
+ */
177
+ export function buildInvocation(task, cwd, baseEnv) {
178
+ const type = normalizeTaskType(task.type);
179
+ const env = buildEnv(task, baseEnv);
180
+ switch (type) {
181
+ case 'node':
182
+ return { type, env, ...buildNodeInvocation(task, cwd) };
183
+ case 'python':
184
+ return { type, env, ...buildPythonInvocation(task, cwd) };
185
+ case 'http':
186
+ return { type, env, http: buildHttpRequest(task) };
187
+ case 'ssh':
188
+ return { type, env, ...buildSshInvocation(task) };
189
+ case 'docker': {
190
+ // Issue #226: do not pass full host process.env into docker run
191
+ const dockerInvocation = buildDockerInvocation(task, task.env);
192
+ return { type, env: dockerInvocation.env, file: dockerInvocation.file, args: dockerInvocation.args };
193
+ }
194
+ case 'script':
195
+ default:
196
+ return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
197
+ }
198
+ }
package/lib/runtimes.js CHANGED
@@ -1,198 +1 @@
1
- import { bestEffort } from './best-effort.js';
2
- /**
3
- * Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
4
- *
5
- * Every builder is a pure function returning the process invocation
6
- * ({ file, args, env }) or the HTTP request description, so the whole matrix
7
- * is unit-testable without spawning anything.
8
- */
9
-
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
-
13
- export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
14
- const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
15
- export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
16
-
17
- /** Types that execute code and therefore need the HTTP confirm header. */
18
- export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
19
-
20
- export function normalizeTaskType(type) {
21
- return TASK_TYPES.includes(type) ? type : 'llm';
22
- }
23
-
24
- /**
25
- * Split a leading script path from trailing CLI arguments.
26
- * Quoted segments are preserved as single arguments.
27
- */
28
- export function splitCommandLine(text) {
29
- const trimmed = String(text || '').trim();
30
- if (!trimmed) return { path: '', args: [] };
31
- const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
32
- const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
33
- return { path: clean[0] || '', args: clean.slice(1) };
34
- }
35
-
36
- function looksLikeScriptFile(token, extensions) {
37
- if (!token) return false;
38
- const ext = path.extname(token).toLowerCase();
39
- if (extensions.includes(ext)) return true;
40
- try {
41
- return fs.existsSync(token) && fs.statSync(token).isFile();
42
- } catch {
43
- return false;
44
- }
45
- }
46
-
47
- /** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
48
- export function buildNodeInvocation(task, cwd) {
49
- const nodeExecutable = task.nodePath || process.execPath;
50
- const { path: filePath, args } = splitCommandLine(task.prompt);
51
- const flags = [];
52
- if (Number(task.maxMemoryMb) > 0) {
53
- flags.push(`--max-old-space-size=${Number(task.maxMemoryMb)}`);
54
- }
55
- if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
56
- return { file: nodeExecutable, args: [...flags, filePath, ...args] };
57
- }
58
- const code = String(task.prompt || '');
59
- const isEsm = /\bimport\s|\bexport\s/.test(code);
60
- const nodeArgs = isEsm ? [...flags, '--input-type=module', '-e', code] : [...flags, '-e', code];
61
- return { file: nodeExecutable, args: nodeArgs };
62
- }
63
-
64
- /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
65
- function detectPython(task, cwd) {
66
- if (task.pythonPath) return task.pythonPath;
67
- const candidates = [];
68
- if (process.env.VIRTUAL_ENV) {
69
- candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
70
- candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
71
- }
72
- const base = cwd || process.cwd();
73
- candidates.push(path.join(base, '.venv', 'bin', 'python'));
74
- candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
75
- candidates.push(path.join(base, 'venv', 'bin', 'python'));
76
- for (const candidate of candidates) {
77
- const exists = bestEffort('check-python-candidate', () => fs.existsSync(candidate));
78
- if (exists) return candidate;
79
- }
80
- return process.platform === 'win32' ? 'python' : 'python3';
81
- }
82
-
83
- /** #4: resolve a Python invocation (inline code or a .py file). */
84
- export function buildPythonInvocation(task, cwd) {
85
- const interpreter = detectPython(task, cwd);
86
- const { path: filePath, args } = splitCommandLine(task.prompt);
87
- if (looksLikeScriptFile(filePath, ['.py'])) {
88
- return { file: interpreter, args: [filePath, ...args] };
89
- }
90
- return { file: interpreter, args: ['-c', String(task.prompt || '')] };
91
- }
92
-
93
- /** #7: build the HTTP trigger request description. */
94
- export function buildHttpRequest(task) {
95
- const raw = String(task.httpUrl || task.prompt || '').trim();
96
- if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
97
- let url;
98
- try {
99
- url = new URL(raw);
100
- } catch {
101
- throw new Error(`Invalid HTTP URL: ${raw}`);
102
- }
103
- let headers = {};
104
- if (task.httpHeaders) {
105
- if (typeof task.httpHeaders === 'object') {
106
- headers = { ...task.httpHeaders };
107
- } else {
108
- try {
109
- headers = JSON.parse(String(task.httpHeaders));
110
- } catch {
111
- throw new Error('httpHeaders must be a JSON object');
112
- }
113
- }
114
- }
115
- const method = String(task.httpMethod || 'GET').toUpperCase();
116
- const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
117
- return { url: url.toString(), method, headers, body };
118
- }
119
-
120
- /** #9: build a remote SSH invocation. */
121
- export function buildSshInvocation(task) {
122
- const target = String(task.sshTarget || '').trim();
123
- if (!target) throw new Error('SSH task requires sshTarget (user@host)');
124
- const args = [];
125
- if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
126
- if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
127
- args.push(target, String(task.prompt || 'true'));
128
- return { file: 'ssh', args };
129
- }
130
-
131
- /** #8/#226: build Docker run invocation without leaking host process secrets into container. */
132
- export function buildDockerInvocation(task, explicitEnv) {
133
- const image = String(task.dockerImage || '').trim();
134
- if (!image) throw new Error('Docker task requires dockerImage');
135
- const args = ['run', '--rm'];
136
-
137
- // Issue #226: isolate container environment. If explicitEnv is passed directly
138
- // (e.g. test or explicit invocation), use it; otherwise use task.env only.
139
- let envMap = explicitEnv;
140
- if (!envMap) {
141
- envMap = {};
142
- if (task && task.env && typeof task.env === 'object') {
143
- for (const [k, v] of Object.entries(task.env)) {
144
- if (v !== null && v !== undefined) envMap[k] = String(v);
145
- }
146
- }
147
- }
148
-
149
- for (const [key, value] of Object.entries(envMap)) {
150
- if (key === 'PATH' || key === 'HOME') continue; // container owns those
151
- if (value === null || value === undefined) continue;
152
- args.push('-e', `${key}=${value}`);
153
- }
154
- if (Number(task.maxMemoryMb) > 0) args.push(`--memory=${Number(task.maxMemoryMb)}m`);
155
- if (Number(task.cpus) > 0) args.push(`--cpus=${Number(task.cpus)}`);
156
- if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
157
- args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
158
- return { file: 'docker', args, env: envMap };
159
- }
160
-
161
- /** #38: merge task env vars over the base environment. */
162
- export function buildEnv(task, baseEnv) {
163
- const env = { ...(baseEnv || process.env) };
164
- if (task && task.env && typeof task.env === 'object') {
165
- for (const [key, value] of Object.entries(task.env)) {
166
- if (value === null || value === undefined) delete env[key];
167
- else env[key] = String(value);
168
- }
169
- }
170
- return env;
171
- }
172
-
173
- /**
174
- * Dispatch an external (non-LLM) runtime to its invocation.
175
- * Returns { file, args } plus an optional `http` descriptor for the fetch path.
176
- */
177
- export function buildInvocation(task, cwd, baseEnv) {
178
- const type = normalizeTaskType(task.type);
179
- const env = buildEnv(task, baseEnv);
180
- switch (type) {
181
- case 'node':
182
- return { type, env, ...buildNodeInvocation(task, cwd) };
183
- case 'python':
184
- return { type, env, ...buildPythonInvocation(task, cwd) };
185
- case 'http':
186
- return { type, env, http: buildHttpRequest(task) };
187
- case 'ssh':
188
- return { type, env, ...buildSshInvocation(task) };
189
- case 'docker': {
190
- // Issue #226: do not pass full host process.env into docker run
191
- const dockerInvocation = buildDockerInvocation(task, task.env);
192
- return { type, env: dockerInvocation.env, file: dockerInvocation.file, args: dockerInvocation.args };
193
- }
194
- case 'script':
195
- default:
196
- return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
197
- }
198
- }
1
+ export * from './runtimes/runtimes.js';
@@ -0,0 +1,6 @@
1
+ export * from './scheduler.js';
2
+ export * from './scheduler-execution.js';
3
+ export * from './scheduler-completion.js';
4
+ export * from './scheduler-lifecycle.js';
5
+ export * from './scheduler-assists.js';
6
+ export * from './scheduler-preflight.js';
@@ -0,0 +1,142 @@
1
+ import { inspectFailure } from '../failure-inspector.js';
2
+ import { applySilentRule } from '../silent-rule.js';
3
+ import { logger } from '../logger.js';
4
+ import { shouldAutoHeal, applyDoctorFix } from '../doctor.js';
5
+
6
+ /**
7
+ * Ask the model to diagnose a failed run (#43). Never throws.
8
+ */
9
+ export async function inspectFailureRun(task, runInfo, { askModel, readSettingsFn, inspectTimeoutMs }) {
10
+ if (typeof askModel !== 'function') return { inspected: false, error: 'no model is available' };
11
+ const settings = readSettingsFn ? readSettingsFn() : {};
12
+ try {
13
+ return await inspectFailure({
14
+ task,
15
+ runInfo,
16
+ ask: askModel,
17
+ preferredModel: String(settings.inspectorModel || ''),
18
+ timeoutMs: inspectTimeoutMs || 25000,
19
+ });
20
+ } catch (err) {
21
+ return { inspected: false, error: err.message };
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Ask the silent rule whether this run should be delivered (#44).
27
+ * Never throws: any problem means "deliver".
28
+ */
29
+ export async function applySilentRuleToRun(task, runInfo, { askModel, readSettingsFn, silentRuleTimeoutMs }) {
30
+ if (typeof askModel !== 'function') return { skipped: false, reason: '' };
31
+ const preferredModel = String((readSettingsFn ? readSettingsFn() : {}).silentRuleModel || '');
32
+ try {
33
+ return await applySilentRule({
34
+ task,
35
+ runInfo,
36
+ ask: askModel,
37
+ preferredModel,
38
+ timeoutMs: silentRuleTimeoutMs || 20000,
39
+ });
40
+ } catch (err) {
41
+ return { skipped: false, reason: '', verdictError: err.message };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Run the model-assisted steps for a finished run.
47
+ */
48
+ export async function applyModelAssists(task, runInfo, options = {}) {
49
+ const inspection = await inspectFailureRun(task, runInfo, options);
50
+ if (inspection.inspected) {
51
+ runInfo.diagnosis = inspection.diagnosis;
52
+ runInfo.suggestion = inspection.suggestion;
53
+ runInfo.confidence = inspection.confidence;
54
+ runInfo.doctorReport = inspection.doctorReport || {
55
+ category: inspection.category || 'runtime_error',
56
+ cause: inspection.diagnosis,
57
+ suggestedFix: inspection.suggestion,
58
+ confidence: inspection.confidence,
59
+ diff: inspection.diff || '',
60
+ timestamp: Date.now(),
61
+ applied: false,
62
+ autoHealed: false,
63
+ };
64
+ logger.info(`[dsh-cron] task "${task.title}" failure diagnosed (${inspection.confidence}): ${inspection.diagnosis}`);
65
+
66
+ // Safe Auto-Heal Check (#353)
67
+ if (shouldAutoHeal(task, runInfo.doctorReport)) {
68
+ try {
69
+ const healResult = await applyDoctorFix(task, runInfo.doctorReport, {
70
+ store: options.store,
71
+ user: 'scheduler:auto-heal',
72
+ isAutoHeal: true,
73
+ });
74
+ if (healResult.ok) {
75
+ logger.info(`[dsh-cron] task "${task.title}" auto-healed successfully with doctor fix!`);
76
+ runInfo.autoHealed = true;
77
+ if (runInfo.doctorReport) runInfo.doctorReport.autoHealed = true;
78
+ }
79
+ } catch (autoHealErr) {
80
+ logger.warn(`[dsh-cron] failed to auto-heal task "${task.title}":`, autoHealErr.message);
81
+ }
82
+ }
83
+ } else if (inspection.error && (task.inspectOnFailure || task.autoDiagnose || task.autoHeal)) {
84
+ logger.warn(`[dsh-cron] failure inspection for "${task.title}" not applied: ${inspection.error}`);
85
+ }
86
+
87
+ const silent = await applySilentRuleToRun(task, runInfo, options);
88
+ if (silent.skipped) {
89
+ runInfo.silentSkip = true;
90
+ runInfo.silentReason = silent.reason;
91
+ logger.info(`[dsh-cron] task "${task.title}" stayed silent by rule: ${silent.reason || 'no reason given'}`);
92
+ } else if (silent.verdictError) {
93
+ logger.warn(`[dsh-cron] silent rule for "${task.title}" not applied: ${silent.verdictError}`);
94
+ }
95
+ return silent;
96
+ }
97
+
98
+ /**
99
+ * Deliver a finished run to every configured channel (#26).
100
+ */
101
+ export async function deliverNotifications(task, runInfo, { store, resolveSecrets, deliver, secretTimeoutMs = 5000 }) {
102
+ try {
103
+ let settings = typeof store?.getSettings === 'function' ? store.getSettings() : {};
104
+ if (resolveSecrets) {
105
+ try {
106
+ let timer;
107
+ const deadline = new Promise((_, reject) => {
108
+ timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${secretTimeoutMs}ms`)), secretTimeoutMs);
109
+ if (typeof timer.unref === 'function') timer.unref();
110
+ });
111
+ const resolved = await Promise.race([
112
+ Promise.resolve(resolveSecrets(settings)).finally(() => {
113
+ if (timer) clearTimeout(timer);
114
+ }),
115
+ deadline,
116
+ ]);
117
+ settings = { ...settings, ...(resolved || {}) };
118
+ } catch (secErr) {
119
+ logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
120
+ }
121
+ }
122
+ if (typeof deliver !== 'function') return { status: 'skipped', delivered: [], failures: [] };
123
+ const result = await deliver(task, runInfo, settings);
124
+ if (result && Array.isArray(result.delivered) && result.delivered.length) {
125
+ logger.info(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
126
+ }
127
+ if (result && Array.isArray(result.failures) && result.failures.length) {
128
+ logger.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
129
+ }
130
+ const delivered = Array.isArray(result?.delivered) ? result.delivered : [];
131
+ const failures = Array.isArray(result?.failures) ? result.failures : [];
132
+ let status = 'skipped';
133
+ if (delivered.length > 0 && failures.length === 0) status = 'delivered';
134
+ else if (delivered.length > 0 && failures.length > 0) status = 'partial';
135
+ else if (delivered.length === 0 && failures.length > 0) status = 'failed';
136
+ return { status, delivered, failures };
137
+ } catch (notifyErr) {
138
+ logger.error('[dsh-cron] notification error:', notifyErr.message);
139
+ return { status: 'failed', delivered: [], failures: [{ channel: 'all', error: notifyErr.message }] };
140
+ }
141
+ }
142
+