@goodandready/dsh-cron 0.3.25 → 0.3.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -191
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +2 -1
package/lib/runner-external.js
CHANGED
|
@@ -1,352 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { logger } from './logger.js';
|
|
3
|
-
import { bestEffort } from './best-effort.js';
|
|
4
|
-
import {
|
|
5
|
-
buildInvocation,
|
|
6
|
-
} from './runtimes.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Helper to detect context window overflow errors in persistent sessions (#145).
|
|
10
|
-
*/
|
|
11
|
-
export function isContextOverflowError(err) {
|
|
12
|
-
const msg = String(err?.message || err || '').toLowerCase();
|
|
13
|
-
return msg.includes('context length') ||
|
|
14
|
-
msg.includes('context window') ||
|
|
15
|
-
msg.includes('maximum context') ||
|
|
16
|
-
msg.includes('too many tokens') ||
|
|
17
|
-
msg.includes('prompt too long') ||
|
|
18
|
-
msg.includes('token limit') ||
|
|
19
|
-
msg.includes('context_length_exceeded') ||
|
|
20
|
-
msg.includes('string_above_max_length');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Cross-platform process tree killer (#145).
|
|
25
|
-
* On Windows, calls `taskkill /pid <pid> /T /F` to terminate the entire process hierarchy.
|
|
26
|
-
*/
|
|
27
|
-
export function terminateProcessTree(child, sig = 'SIGTERM', { isPosix = process.platform !== 'win32', execFn } = {}) {
|
|
28
|
-
if (!child || child.killed) return;
|
|
29
|
-
bestEffort('terminate-process-tree', () => {
|
|
30
|
-
if (isPosix && child.pid) {
|
|
31
|
-
bestEffort('kill-posix-pid', () => process.kill(-child.pid, sig));
|
|
32
|
-
} else if (child.pid) {
|
|
33
|
-
const runner = execFn || ((cmd) => {
|
|
34
|
-
bestEffort('exec-taskkill-fn', () => {
|
|
35
|
-
import('node:child_process').then(({ exec }) => {
|
|
36
|
-
exec(cmd, { windowsHide: true }, () => {});
|
|
37
|
-
}).catch(() => {});
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
bestEffort('exec-taskkill', () => runner(`taskkill /pid ${child.pid} /T /F`));
|
|
41
|
-
bestEffort('child-kill', () => child.kill(sig));
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Helper to test if an error code or status is transient and eligible for retry (#134).
|
|
48
|
-
*/
|
|
49
|
-
export function isTransientError(errOrStatus) {
|
|
50
|
-
if (typeof errOrStatus === 'number') {
|
|
51
|
-
return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
|
|
52
|
-
}
|
|
53
|
-
const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
|
|
54
|
-
return str.includes('429') || str.includes('too many requests') ||
|
|
55
|
-
str.includes('502') || str.includes('bad gateway') ||
|
|
56
|
-
str.includes('503') || str.includes('service unavailable') ||
|
|
57
|
-
str.includes('504') || str.includes('gateway timeout') ||
|
|
58
|
-
str.includes('econnreset') || str.includes('etimedout');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** HTTP trigger runtime (#7) with transient retry (#134). */
|
|
62
|
-
export async function runHttp(task, request, signal) {
|
|
63
|
-
const streamRunId = `run_${Date.now()}`;
|
|
64
|
-
logStreamer.startStream(task.id, streamRunId);
|
|
65
|
-
logStreamer.appendLog(task.id, `> ${request.method} ${request.url}
|
|
66
|
-
`, 'stdout');
|
|
67
|
-
let attempts = 0;
|
|
68
|
-
const maxAttempts = 3;
|
|
69
|
-
while (attempts < maxAttempts) {
|
|
70
|
-
attempts++;
|
|
71
|
-
try {
|
|
72
|
-
const res = await fetch(request.url, {
|
|
73
|
-
method: request.method,
|
|
74
|
-
headers: request.headers,
|
|
75
|
-
body: request.body,
|
|
76
|
-
signal,
|
|
77
|
-
});
|
|
78
|
-
const text = await res.text().catch(() => '');
|
|
79
|
-
const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
|
|
80
|
-
if (!res.ok) {
|
|
81
|
-
const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
|
|
82
|
-
if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
|
|
83
|
-
logger.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
84
|
-
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
throw err;
|
|
88
|
-
}
|
|
89
|
-
return {
|
|
90
|
-
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
91
|
-
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
92
|
-
costUsd: 0,
|
|
93
|
-
};
|
|
94
|
-
} catch (err) {
|
|
95
|
-
if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
|
|
96
|
-
logger.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
97
|
-
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
throw err;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* #9 reuse-first path: when the task names a dsh-remote-workspace profile,
|
|
107
|
-
* execute through that plugin's `remoteSsh` service so host settings and
|
|
108
|
-
* credentials stay owned by it (we only reference the profile id).
|
|
109
|
-
* Returns null when the service or the profile is unavailable so the caller
|
|
110
|
-
* can fall back to a direct ssh invocation.
|
|
111
|
-
*/
|
|
112
|
-
export async function runViaRemoteWorkspace(task, signal, ctx) {
|
|
113
|
-
if (!task.sshProfileId || !ctx || typeof ctx.get !== 'function') return null;
|
|
114
|
-
let svc = null;
|
|
115
|
-
svc = bestEffort('get-remoteSsh', () => ctx.get('remoteSsh'));
|
|
116
|
-
if (!svc || typeof svc.exec !== 'function') return null;
|
|
117
|
-
|
|
118
|
-
let profile = null;
|
|
119
|
-
profile = bestEffort('get-remoteSsh-profile', () => {
|
|
120
|
-
const remoteProfilesSvc = ctx.get('remoteProfiles');
|
|
121
|
-
if (remoteProfilesSvc && typeof remoteProfilesSvc.list === 'function') {
|
|
122
|
-
const list = remoteProfilesSvc.list();
|
|
123
|
-
if (Array.isArray(list)) {
|
|
124
|
-
const found = list.find((p) => p && p.id === task.sshProfileId);
|
|
125
|
-
if (found) return found;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const settings = ctx.get('settings');
|
|
129
|
-
if (settings) {
|
|
130
|
-
if (typeof settings.describe === 'function') {
|
|
131
|
-
const desc = settings.describe();
|
|
132
|
-
const entry = Array.isArray(desc) ? desc.find((r) => r && r.ns === 'dsh-remote-workspace') : null;
|
|
133
|
-
const snap = entry ? entry.value : null;
|
|
134
|
-
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
135
|
-
const found = profiles.find((p) => p && p.id === task.sshProfileId);
|
|
136
|
-
if (found) return found;
|
|
137
|
-
}
|
|
138
|
-
if (typeof settings.get === 'function') {
|
|
139
|
-
const snap = settings.get('dsh-remote-workspace');
|
|
140
|
-
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
141
|
-
return profiles.find((p) => p && p.id === task.sshProfileId) || null;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
});
|
|
146
|
-
if (!profile) {
|
|
147
|
-
throw new Error(`remote-workspace profile "${task.sshProfileId}" was not found — check dsh-remote-workspace settings, or use sshTarget instead`);
|
|
148
|
-
}
|
|
149
|
-
const res = await svc.exec(profile, String(task.prompt || 'true'), task.cwd || profile.remoteWorkspace || undefined);
|
|
150
|
-
const output = [res && res.stdout, res && res.stderr].filter((x) => x && String(x).trim()).join('\n').trim();
|
|
151
|
-
const code = res && typeof res.code === 'number' ? res.code : 0;
|
|
152
|
-
if (code !== 0) {
|
|
153
|
-
throw new Error(`Remote command exited with code ${code}\n${output}`.slice(0, 4000));
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
output: output || 'Remote command completed with code 0',
|
|
157
|
-
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
158
|
-
costUsd: 0,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
|
|
163
|
-
export async function runExternal(task, runtime, signal, execOptions = {}, { ctx = null, resolveCwdFn = null } = {}) {
|
|
164
|
-
if (runtime === 'ssh' && task.sshProfileId) {
|
|
165
|
-
const viaService = await runViaRemoteWorkspace(task, signal, ctx);
|
|
166
|
-
if (viaService) return viaService;
|
|
167
|
-
if (!task.sshTarget) {
|
|
168
|
-
throw new Error('SSH task needs either a working dsh-remote-workspace profile (sshProfileId) or an sshTarget');
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
const cwd = resolveCwdFn ? resolveCwdFn(task) : (task.cwd || process.cwd());
|
|
172
|
-
const invocation = buildInvocation({ ...task, type: runtime }, cwd);
|
|
173
|
-
if (invocation.http) return runHttp(task, invocation.http, signal);
|
|
174
|
-
|
|
175
|
-
const { spawn, exec } = await import('node:child_process');
|
|
176
|
-
return new Promise((resolve, reject) => {
|
|
177
|
-
let child = null;
|
|
178
|
-
let settled = false;
|
|
179
|
-
let timedOut = false;
|
|
180
|
-
let stdout = '';
|
|
181
|
-
let stderr = '';
|
|
182
|
-
let truncated = false;
|
|
183
|
-
// Issue #393: maxLogBytesPerRun cap (default 5MB)
|
|
184
|
-
const maxLogBytes = Number(task.maxLogBytesPerRun) > 0 ? Number(task.maxLogBytesPerRun) : 5 * 1024 * 1024;
|
|
185
|
-
const isPosix = process.platform !== 'win32';
|
|
186
|
-
|
|
187
|
-
// Issue #393: Execution timeout guardrail (default 15 minutes = 900,000ms, or task.executionTimeoutMs / timeoutSeconds)
|
|
188
|
-
const effectiveTimeoutMs = Number(task.executionTimeoutMs) > 0
|
|
189
|
-
? Number(task.executionTimeoutMs)
|
|
190
|
-
: (Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) * 1000 : 15 * 60 * 1000);
|
|
191
|
-
|
|
192
|
-
let timeoutTimer = null;
|
|
193
|
-
let forceKillTimer = null;
|
|
194
|
-
|
|
195
|
-
const killProcessTree = (sig = 'SIGTERM') => {
|
|
196
|
-
terminateProcessTree(child, sig, {
|
|
197
|
-
isPosix,
|
|
198
|
-
execFn: (cmd) => {
|
|
199
|
-
bestEffort('exec-cmd', () => exec(cmd, { windowsHide: true }, () => {}));
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const cleanupTimers = () => {
|
|
205
|
-
if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
|
|
206
|
-
if (forceKillTimer) { clearTimeout(forceKillTimer); forceKillTimer = null; }
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
if (effectiveTimeoutMs > 0) {
|
|
210
|
-
timeoutTimer = setTimeout(() => {
|
|
211
|
-
if (settled) return;
|
|
212
|
-
timedOut = true;
|
|
213
|
-
logger.warn(`[dsh-cron] task "${task.title || task.id}" timed out after ${effectiveTimeoutMs}ms, sending SIGTERM`);
|
|
214
|
-
killProcessTree('SIGTERM');
|
|
215
|
-
forceKillTimer = setTimeout(() => {
|
|
216
|
-
if (!settled) {
|
|
217
|
-
logger.warn(`[dsh-cron] task "${task.title || task.id}" force killing with SIGKILL`);
|
|
218
|
-
killProcessTree('SIGKILL');
|
|
219
|
-
}
|
|
220
|
-
}, 3000);
|
|
221
|
-
if (typeof forceKillTimer.unref === 'function') forceKillTimer.unref();
|
|
222
|
-
}, effectiveTimeoutMs);
|
|
223
|
-
if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const abortHandler = () => {
|
|
227
|
-
cleanupTimers();
|
|
228
|
-
killProcessTree('SIGTERM');
|
|
229
|
-
setTimeout(() => killProcessTree('SIGKILL'), 300);
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
if (signal) {
|
|
233
|
-
if (signal.aborted) {
|
|
234
|
-
cleanupTimers();
|
|
235
|
-
return reject(signal.reason || new Error('Command aborted'));
|
|
236
|
-
}
|
|
237
|
-
signal.addEventListener('abort', abortHandler, { once: true });
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const runEnv = { ...invocation.env };
|
|
241
|
-
if (execOptions?.prevOutput !== undefined) {
|
|
242
|
-
runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
|
|
243
|
-
}
|
|
244
|
-
const streamRunId = execOptions?.runId || `run_${Date.now()}`;
|
|
245
|
-
logStreamer.startStream(task.id, streamRunId);
|
|
246
|
-
const options = {
|
|
247
|
-
cwd,
|
|
248
|
-
env: runEnv,
|
|
249
|
-
detached: isPosix,
|
|
250
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
if (invocation.file) {
|
|
254
|
-
child = spawn(invocation.file, invocation.args || [], options);
|
|
255
|
-
} else {
|
|
256
|
-
let command = invocation.shellCommand;
|
|
257
|
-
if (isPosix && Number(task.maxMemoryMb) > 0) {
|
|
258
|
-
command = `ulimit -v ${Number(task.maxMemoryMb) * 1024} 2>/dev/null || true; ${command}`;
|
|
259
|
-
}
|
|
260
|
-
const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
|
|
261
|
-
const shellArgs = isPosix ? ['-c', command] : ['/d', '/s', '/c', command];
|
|
262
|
-
child = spawn(shellPath, shellArgs, options);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
child.stdout.on('data', (chunk) => {
|
|
266
|
-
const remaining = maxLogBytes - (stdout.length + stderr.length);
|
|
267
|
-
const text = chunk.toString('utf-8');
|
|
268
|
-
if (remaining > 0) {
|
|
269
|
-
if (text.length <= remaining) {
|
|
270
|
-
stdout += text;
|
|
271
|
-
if (!truncated) logStreamer.appendLog(task.id, text, 'stdout');
|
|
272
|
-
} else {
|
|
273
|
-
stdout += text.slice(0, remaining);
|
|
274
|
-
if (!truncated) {
|
|
275
|
-
truncated = true;
|
|
276
|
-
const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
|
|
277
|
-
stdout += note;
|
|
278
|
-
logStreamer.appendLog(task.id, text.slice(0, remaining) + note, 'stdout');
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
} else if (!truncated) {
|
|
282
|
-
truncated = true;
|
|
283
|
-
const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
|
|
284
|
-
stdout += note;
|
|
285
|
-
logStreamer.appendLog(task.id, note, 'stdout');
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
child.stderr.on('data', (chunk) => {
|
|
290
|
-
const remaining = maxLogBytes - (stdout.length + stderr.length);
|
|
291
|
-
const text = chunk.toString('utf-8');
|
|
292
|
-
if (remaining > 0) {
|
|
293
|
-
if (text.length <= remaining) {
|
|
294
|
-
stderr += text;
|
|
295
|
-
if (!truncated) logStreamer.appendLog(task.id, text, 'stderr');
|
|
296
|
-
} else {
|
|
297
|
-
stderr += text.slice(0, remaining);
|
|
298
|
-
if (!truncated) {
|
|
299
|
-
truncated = true;
|
|
300
|
-
const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
|
|
301
|
-
stderr += note;
|
|
302
|
-
logStreamer.appendLog(task.id, text.slice(0, remaining) + note, 'stderr');
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
} else if (!truncated) {
|
|
306
|
-
truncated = true;
|
|
307
|
-
const note = `\n[TRUNCATED: log output exceeded maxLogBytesPerRun (${Math.round(maxLogBytes / 1024 / 1024)}MB)]`;
|
|
308
|
-
stderr += note;
|
|
309
|
-
logStreamer.appendLog(task.id, note, 'stderr');
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
child.on('error', (err) => {
|
|
314
|
-
if (settled) return;
|
|
315
|
-
settled = true;
|
|
316
|
-
cleanupTimers();
|
|
317
|
-
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
318
|
-
logStreamer.completeStream(task.id, { status: 'error', error: err.message, exitCode: 1 });
|
|
319
|
-
reject(err);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
child.on('close', (code, sig) => {
|
|
323
|
-
if (settled) return;
|
|
324
|
-
settled = true;
|
|
325
|
-
cleanupTimers();
|
|
326
|
-
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
327
|
-
|
|
328
|
-
if (timedOut) {
|
|
329
|
-
logStreamer.completeStream(task.id, { status: 'timeout', error: `Execution timed out after ${effectiveTimeoutMs}ms`, exitCode: 124 });
|
|
330
|
-
return reject(new Error(`Execution timed out after ${effectiveTimeoutMs}ms (SIGTERM/SIGKILL sent)`));
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (signal && signal.aborted) {
|
|
334
|
-
return reject(signal.reason || new Error('Command aborted'));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (code !== 0 && code !== null) {
|
|
338
|
-
const detail = String(stderr || '').trim();
|
|
339
|
-
const msg = `${runtime} task exited with code ${code}`;
|
|
340
|
-
return reject(new Error(detail ? `${msg}\n${detail}` : msg));
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const out = (stdout || stderr || '').trim();
|
|
344
|
-
resolve({
|
|
345
|
-
output: out || `${runtime} task completed with code 0`,
|
|
346
|
-
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
347
|
-
costUsd: 0,
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
|
|
1
|
+
export * from './runtimes/runner-external.js';
|
package/lib/runner-worktree.js
CHANGED
|
@@ -1,90 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { bestEffort } from './best-effort.js';
|
|
3
|
-
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Resolve the working directory for a task: explicit cwd, a bound
|
|
8
|
-
* workspace id resolved through the harness, or the process cwd (#31).
|
|
9
|
-
*/
|
|
10
|
-
export function resolveCwd(task, ctx = null) {
|
|
11
|
-
if (task.cwd) return String(task.cwd);
|
|
12
|
-
if (task.workspaceId) {
|
|
13
|
-
const rawId = String(task.workspaceId).trim();
|
|
14
|
-
if (ctx) {
|
|
15
|
-
const resolved = bestEffort('resolve-workspace-path', () => {
|
|
16
|
-
// Issue #363 / GH #12: host workspace service is 'workspaceRegistry' in DeepSeek Harness
|
|
17
|
-
const registry =
|
|
18
|
-
(typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : null) ||
|
|
19
|
-
ctx.workspaceRegistry ||
|
|
20
|
-
(typeof ctx.get === 'function' ? ctx.get('workspaces') : null) ||
|
|
21
|
-
ctx.workspaces;
|
|
22
|
-
|
|
23
|
-
if (registry) {
|
|
24
|
-
// 1. Direct get(id) lookup on WorkspaceRegistry
|
|
25
|
-
if (typeof registry.get === 'function') {
|
|
26
|
-
const ws = registry.get(rawId);
|
|
27
|
-
if (ws && ws.path) return String(ws.path);
|
|
28
|
-
}
|
|
29
|
-
// 2. List lookup if ID, title, or Path was provided ("Workspace id or path")
|
|
30
|
-
if (typeof registry.list === 'function') {
|
|
31
|
-
const list = registry.list();
|
|
32
|
-
if (Array.isArray(list)) {
|
|
33
|
-
const matched = list.find((w) => w && (w.id === rawId || w.path === rawId || w.title === rawId));
|
|
34
|
-
if (matched && matched.path) return String(matched.path);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
});
|
|
40
|
-
if (resolved) return resolved;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// 3. Fallback: if task.workspaceId directly points to an existing directory path on disk
|
|
44
|
-
try {
|
|
45
|
-
if (fs.existsSync(rawId) && fs.statSync(rawId).isDirectory()) {
|
|
46
|
-
return rawId;
|
|
47
|
-
}
|
|
48
|
-
} catch (_fsErr) {
|
|
49
|
-
// ignore filesystem stat error
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Issue #231: fail-closed if workspaceId was specified but cannot be resolved
|
|
53
|
-
throw new Error(`Workspace "${task.workspaceId}" requested by task could not be resolved`);
|
|
54
|
-
}
|
|
55
|
-
return process.cwd();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Create an isolated git worktree for a run when task.worktree is requested (#29).
|
|
60
|
-
* Returns the directory path of the worktree.
|
|
61
|
-
*/
|
|
62
|
-
export async function createTaskWorktree(cwd, task) {
|
|
63
|
-
const { execFile } = await import('node:child_process');
|
|
64
|
-
const { randomUUID } = await import('node:crypto');
|
|
65
|
-
const branch = `cron-${task.id}-${randomUUID().slice(0, 8)}`;
|
|
66
|
-
const wtPath = `${cwd}/.worktrees/${branch}`;
|
|
67
|
-
return new Promise((resolve, reject) => {
|
|
68
|
-
execFile('git', ['-C', cwd, 'worktree', 'add', '-b', branch, wtPath, 'HEAD'], (err) => {
|
|
69
|
-
if (err) return reject(new Error(`Failed to create worktree: ${err.message}`));
|
|
70
|
-
resolve(wtPath);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Clean up the isolated worktree after execution unless keepWorktree is set (#29).
|
|
77
|
-
* Uses best-effort removal without forcing, followed by git worktree prune.
|
|
78
|
-
*/
|
|
79
|
-
export async function removeTaskWorktree(cwd, wtPath) {
|
|
80
|
-
const { execFile } = await import('node:child_process');
|
|
81
|
-
await new Promise((resolve) => {
|
|
82
|
-
execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
83
|
-
if (err) {
|
|
84
|
-
logger.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
|
|
85
|
-
}
|
|
86
|
-
execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
1
|
+
export * from './runtimes/runner-worktree.js';
|