@goodandready/dsh-cron 0.2.2 → 0.2.4
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 +99 -14
- package/docs/README.ru.md +96 -15
- package/docs/README.zh.md +99 -14
- package/docs/design/DESIGN.md +13 -7
- package/lib/channels.js +422 -0
- package/lib/client.js +697 -26
- package/lib/index.js +278 -19
- package/lib/integrations.js +3 -1
- package/lib/runner.js +214 -29
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +179 -73
- package/lib/secrets.js +123 -0
- package/lib/store.js +92 -21
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
package/lib/runner.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { estimateTokenCost } from './integrations.js';
|
|
2
|
+
import {
|
|
3
|
+
EXTERNAL_RUNTIMES,
|
|
4
|
+
normalizeTaskType,
|
|
5
|
+
buildInvocation,
|
|
6
|
+
} from './runtimes.js';
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Runner executing scheduled tasks in DeepSeek Harness sessions.
|
|
@@ -43,35 +48,164 @@ export class SessionRunner {
|
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the working directory for a task: explicit cwd, a bound
|
|
53
|
+
* workspace id resolved through the harness, or the process cwd (#31).
|
|
54
|
+
*/
|
|
55
|
+
resolveCwd(task) {
|
|
56
|
+
if (task.cwd) return String(task.cwd);
|
|
57
|
+
if (task.workspaceId && this.ctx && typeof this.ctx.get === 'function') {
|
|
58
|
+
try {
|
|
59
|
+
const workspaces = this.ctx.get('workspaces');
|
|
60
|
+
const ws = workspaces && (typeof workspaces.get === 'function'
|
|
61
|
+
? workspaces.get(task.workspaceId)
|
|
62
|
+
: (workspaces[task.workspaceId] || null));
|
|
63
|
+
const resolved = ws && (ws.path || ws.cwd || ws.root || ws.dir);
|
|
64
|
+
if (resolved) return String(resolved);
|
|
65
|
+
} catch {}
|
|
66
|
+
return String(task.workspaceId);
|
|
67
|
+
}
|
|
68
|
+
return process.cwd();
|
|
69
|
+
}
|
|
48
70
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
/** Create an isolated git worktree for a code-modifying task (#29). */
|
|
72
|
+
async createTaskWorktree(cwd, task) {
|
|
73
|
+
const { execFile } = await import('node:child_process');
|
|
74
|
+
const path = await import('node:path');
|
|
75
|
+
const wtPath = path.join(cwd, '.dsh-cron-worktrees', `${task.id}-${Date.now()}`);
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
execFile('git', ['-C', cwd, 'worktree', 'add', '--detach', wtPath, 'HEAD'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
78
|
+
if (err) reject(new Error(String(stderr || err.message).trim()));
|
|
79
|
+
else resolve(stdout);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
return wtPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remove a task worktree; never throws (best-effort cleanup).
|
|
87
|
+
* Plain removal only — no force flags anywhere (workspace rule). A dirty
|
|
88
|
+
* worktree is left in place and reported instead of being force-deleted.
|
|
89
|
+
*/
|
|
90
|
+
async removeTaskWorktree(cwd, wtPath) {
|
|
91
|
+
const { execFile } = await import('node:child_process');
|
|
92
|
+
await new Promise((resolve) => {
|
|
93
|
+
execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
|
|
96
|
+
}
|
|
97
|
+
execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** HTTP trigger runtime (#7). */
|
|
103
|
+
async _runHttp(task, request, signal) {
|
|
104
|
+
const res = await fetch(request.url, {
|
|
105
|
+
method: request.method,
|
|
106
|
+
headers: request.headers,
|
|
107
|
+
body: request.body,
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
const text = await res.text().catch(() => '');
|
|
111
|
+
const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
throw new Error(`${summary}\n${String(text).slice(0, 500)}`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
117
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
118
|
+
costUsd: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* #9 reuse-first path: when the task names a dsh-remote-workspace profile,
|
|
124
|
+
* execute through that plugin's `remoteSsh` service so host settings and
|
|
125
|
+
* credentials stay owned by it (we only reference the profile id).
|
|
126
|
+
* Returns null when the service or the profile is unavailable so the caller
|
|
127
|
+
* can fall back to a direct ssh invocation.
|
|
128
|
+
*/
|
|
129
|
+
async _runViaRemoteWorkspace(task, signal) {
|
|
130
|
+
if (!task.sshProfileId || !this.ctx || typeof this.ctx.get !== 'function') return null;
|
|
131
|
+
let svc = null;
|
|
132
|
+
try {
|
|
133
|
+
svc = this.ctx.get('remoteSsh');
|
|
134
|
+
} catch {}
|
|
135
|
+
if (!svc || typeof svc.exec !== 'function') return null;
|
|
136
|
+
|
|
137
|
+
let profile = null;
|
|
138
|
+
try {
|
|
139
|
+
const settings = this.ctx.get('settings');
|
|
140
|
+
if (settings && typeof settings.get === 'function') {
|
|
141
|
+
const snap = settings.get('dsh-remote-workspace');
|
|
142
|
+
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
143
|
+
profile = profiles.find((p) => p && p.id === task.sshProfileId) || null;
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
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
|
+
async _runExternal(task, runtime, signal) {
|
|
164
|
+
if (runtime === 'ssh' && task.sshProfileId) {
|
|
165
|
+
const viaService = await this._runViaRemoteWorkspace(task, signal);
|
|
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 = this.resolveCwd(task);
|
|
172
|
+
const invocation = buildInvocation({ ...task, type: runtime }, cwd);
|
|
173
|
+
if (invocation.http) return this._runHttp(task, invocation.http, signal);
|
|
174
|
+
|
|
175
|
+
const { exec, execFile } = await import('node:child_process');
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const done = (err, stdout, stderr) => {
|
|
178
|
+
if (err) {
|
|
179
|
+
if (signal && signal.aborted) {
|
|
180
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
66
181
|
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
182
|
+
const detail = String(stderr || '').trim();
|
|
183
|
+
return reject(new Error(detail ? `${err.message}\n${detail}` : err.message));
|
|
184
|
+
}
|
|
185
|
+
const out = (stdout || stderr || '').trim();
|
|
186
|
+
resolve({
|
|
187
|
+
output: out || `${runtime} task completed with code 0`,
|
|
188
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
189
|
+
costUsd: 0,
|
|
73
190
|
});
|
|
74
|
-
}
|
|
191
|
+
};
|
|
192
|
+
const options = { cwd, signal, maxBuffer: 10 * 1024 * 1024, env: invocation.env };
|
|
193
|
+
if (invocation.file) {
|
|
194
|
+
execFile(invocation.file, invocation.args || [], options, done);
|
|
195
|
+
} else {
|
|
196
|
+
const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
|
|
197
|
+
exec(invocation.shellCommand, { ...options, shell: shellPath }, done);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _run(task, signal) {
|
|
203
|
+
console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
204
|
+
|
|
205
|
+
// 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
|
|
206
|
+
const runtime = normalizeTaskType(task.type);
|
|
207
|
+
if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
|
|
208
|
+
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
|
|
75
209
|
}
|
|
76
210
|
|
|
77
211
|
// 2. If ctx.agents is available, run through agent session (LLM)
|
|
@@ -121,7 +255,26 @@ export class SessionRunner {
|
|
|
121
255
|
]);
|
|
122
256
|
};
|
|
123
257
|
|
|
124
|
-
const
|
|
258
|
+
const baseCwd = this.resolveCwd(task);
|
|
259
|
+
// #29: code-modifying tasks may run in an isolated git worktree.
|
|
260
|
+
let cwd = baseCwd;
|
|
261
|
+
let taskWorktree = null;
|
|
262
|
+
if (task.worktree) {
|
|
263
|
+
try {
|
|
264
|
+
taskWorktree = await this.createTaskWorktree(baseCwd, task);
|
|
265
|
+
cwd = taskWorktree;
|
|
266
|
+
} catch (wtErr) {
|
|
267
|
+
console.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// #6/#10: skill- and workflow-driven tasks are agent-mediated with a
|
|
271
|
+
// shaped prompt naming the skill/workflow.
|
|
272
|
+
let agentPrompt = String(task.prompt || '');
|
|
273
|
+
if (runtime === 'skill' && task.skillName) {
|
|
274
|
+
agentPrompt = `Use the DSH skill "${task.skillName}" for this task:\n${agentPrompt}`;
|
|
275
|
+
} else if (runtime === 'workflow' && task.workflowName) {
|
|
276
|
+
agentPrompt = `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${agentPrompt}`;
|
|
277
|
+
}
|
|
125
278
|
let handle = null;
|
|
126
279
|
try {
|
|
127
280
|
handle = await this.ctx.agents.create({
|
|
@@ -130,9 +283,21 @@ export class SessionRunner {
|
|
|
130
283
|
agentOptions: provider && model ? { provider, model } : undefined,
|
|
131
284
|
});
|
|
132
285
|
|
|
286
|
+
// Per-job permission preset (#32): read-only | workspace-write | full.
|
|
287
|
+
if (task.permissionPreset && task.permissionPreset !== 'default') {
|
|
288
|
+
try {
|
|
289
|
+
const presets = this.ctx.get && this.ctx.get('permissionPresets');
|
|
290
|
+
if (presets && typeof presets.set === 'function') {
|
|
291
|
+
presets.set(handle.agent.session, task.permissionPreset);
|
|
292
|
+
}
|
|
293
|
+
} catch (ppErr) {
|
|
294
|
+
console.warn('[dsh-cron] permission preset not applied:', ppErr.message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
133
298
|
await raceAbort(handle.agent.whenIdle());
|
|
134
299
|
handle.agent.followup(createUserMessage({
|
|
135
|
-
content: [{ type: 'text', text:
|
|
300
|
+
content: [{ type: 'text', text: agentPrompt }],
|
|
136
301
|
source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
|
|
137
302
|
}));
|
|
138
303
|
|
|
@@ -159,9 +324,14 @@ export class SessionRunner {
|
|
|
159
324
|
return {
|
|
160
325
|
output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
|
|
161
326
|
usage,
|
|
162
|
-
costUsd
|
|
327
|
+
costUsd,
|
|
328
|
+
sessionId: handle.agent.session?.id || null
|
|
163
329
|
};
|
|
164
330
|
} finally {
|
|
331
|
+
let archivedSessionId = null;
|
|
332
|
+
try {
|
|
333
|
+
archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
|
|
334
|
+
} catch {}
|
|
165
335
|
try {
|
|
166
336
|
if (handle && typeof handle.dispose === 'function') {
|
|
167
337
|
await handle.dispose();
|
|
@@ -171,6 +341,21 @@ export class SessionRunner {
|
|
|
171
341
|
} catch (disposeErr) {
|
|
172
342
|
console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
|
|
173
343
|
}
|
|
344
|
+
// Best-effort: keep ephemeral cron sessions out of the main chat
|
|
345
|
+
// list (#30). The archive call degrades silently when the core
|
|
346
|
+
// does not expose it.
|
|
347
|
+
if (archivedSessionId && this.ctx && this.ctx.get) {
|
|
348
|
+
try {
|
|
349
|
+
const sessions = this.ctx.get('sessions');
|
|
350
|
+
if (sessions && typeof sessions.archive === 'function') {
|
|
351
|
+
sessions.archive(archivedSessionId);
|
|
352
|
+
}
|
|
353
|
+
} catch {}
|
|
354
|
+
}
|
|
355
|
+
// #29: dispose the isolated worktree (kept when keepWorktree is set).
|
|
356
|
+
if (taskWorktree && !task.keepWorktree) {
|
|
357
|
+
await this.removeTaskWorktree(baseCwd, taskWorktree);
|
|
358
|
+
}
|
|
174
359
|
}
|
|
175
360
|
} catch (err) {
|
|
176
361
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
package/lib/runtimes.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
|
|
3
|
+
*
|
|
4
|
+
* Every builder is a pure function returning the process invocation
|
|
5
|
+
* ({ file, args, env }) or the HTTP request description, so the whole matrix
|
|
6
|
+
* is unit-testable without spawning anything.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
|
|
13
|
+
export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
|
|
14
|
+
export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
|
|
15
|
+
|
|
16
|
+
/** Types that execute code and therefore need the HTTP confirm header. */
|
|
17
|
+
export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
18
|
+
|
|
19
|
+
export function normalizeTaskType(type) {
|
|
20
|
+
return TASK_TYPES.includes(type) ? type : 'llm';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a leading script path from trailing CLI arguments.
|
|
25
|
+
* Quoted segments are preserved as single arguments.
|
|
26
|
+
*/
|
|
27
|
+
export function splitCommandLine(text) {
|
|
28
|
+
const trimmed = String(text || '').trim();
|
|
29
|
+
if (!trimmed) return { path: '', args: [] };
|
|
30
|
+
const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
31
|
+
const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
|
|
32
|
+
return { path: clean[0] || '', args: clean.slice(1) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function looksLikeScriptFile(token, extensions) {
|
|
36
|
+
if (!token) return false;
|
|
37
|
+
const ext = path.extname(token).toLowerCase();
|
|
38
|
+
if (extensions.includes(ext)) return true;
|
|
39
|
+
try {
|
|
40
|
+
return fs.existsSync(token) && fs.statSync(token).isFile();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
|
|
47
|
+
export function buildNodeInvocation(task, cwd) {
|
|
48
|
+
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
49
|
+
if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
|
|
50
|
+
return { file: process.execPath, args: [filePath, ...args] };
|
|
51
|
+
}
|
|
52
|
+
const code = String(task.prompt || '');
|
|
53
|
+
const isEsm = /\bimport\s|\bexport\s/.test(code);
|
|
54
|
+
const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
|
|
55
|
+
return { file: task.nodePath || process.execPath, args: nodeArgs };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
|
|
59
|
+
export function detectPython(task, cwd) {
|
|
60
|
+
if (task.pythonPath) return task.pythonPath;
|
|
61
|
+
const candidates = [];
|
|
62
|
+
if (process.env.VIRTUAL_ENV) {
|
|
63
|
+
candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
|
|
64
|
+
candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
|
|
65
|
+
}
|
|
66
|
+
const base = cwd || process.cwd();
|
|
67
|
+
candidates.push(path.join(base, '.venv', 'bin', 'python'));
|
|
68
|
+
candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
|
|
69
|
+
candidates.push(path.join(base, 'venv', 'bin', 'python'));
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** #4: resolve a Python invocation (inline code or a .py file). */
|
|
79
|
+
export function buildPythonInvocation(task, cwd) {
|
|
80
|
+
const interpreter = detectPython(task, cwd);
|
|
81
|
+
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
82
|
+
if (looksLikeScriptFile(filePath, ['.py'])) {
|
|
83
|
+
return { file: interpreter, args: [filePath, ...args] };
|
|
84
|
+
}
|
|
85
|
+
return { file: interpreter, args: ['-c', String(task.prompt || '')] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** #7: build the HTTP trigger request description. */
|
|
89
|
+
export function buildHttpRequest(task) {
|
|
90
|
+
const raw = String(task.httpUrl || task.prompt || '').trim();
|
|
91
|
+
if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error(`Invalid HTTP URL: ${raw}`);
|
|
97
|
+
}
|
|
98
|
+
let headers = {};
|
|
99
|
+
if (task.httpHeaders) {
|
|
100
|
+
if (typeof task.httpHeaders === 'object') {
|
|
101
|
+
headers = { ...task.httpHeaders };
|
|
102
|
+
} else {
|
|
103
|
+
try {
|
|
104
|
+
headers = JSON.parse(String(task.httpHeaders));
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error('httpHeaders must be a JSON object');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const method = String(task.httpMethod || 'GET').toUpperCase();
|
|
111
|
+
const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
|
|
112
|
+
return { url: url.toString(), method, headers, body };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** #9: build a remote SSH invocation. */
|
|
116
|
+
export function buildSshInvocation(task) {
|
|
117
|
+
const target = String(task.sshTarget || '').trim();
|
|
118
|
+
if (!target) throw new Error('SSH task requires sshTarget (user@host)');
|
|
119
|
+
const args = [];
|
|
120
|
+
if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
|
|
121
|
+
if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
|
|
122
|
+
args.push(target, String(task.prompt || 'true'));
|
|
123
|
+
return { file: 'ssh', args };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** #8: build a Docker run invocation. */
|
|
127
|
+
export function buildDockerInvocation(task, env) {
|
|
128
|
+
const image = String(task.dockerImage || '').trim();
|
|
129
|
+
if (!image) throw new Error('Docker task requires dockerImage');
|
|
130
|
+
const args = ['run', '--rm'];
|
|
131
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
132
|
+
if (key === 'PATH' || key === 'HOME') continue; // container owns those
|
|
133
|
+
args.push('-e', `${key}=${value}`);
|
|
134
|
+
}
|
|
135
|
+
if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
|
|
136
|
+
args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
|
|
137
|
+
return { file: 'docker', args };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** #38: merge task env vars over the base environment. */
|
|
141
|
+
export function buildEnv(task, baseEnv) {
|
|
142
|
+
const env = { ...(baseEnv || process.env) };
|
|
143
|
+
if (task && task.env && typeof task.env === 'object') {
|
|
144
|
+
for (const [key, value] of Object.entries(task.env)) {
|
|
145
|
+
if (value === null || value === undefined) delete env[key];
|
|
146
|
+
else env[key] = String(value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return env;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch an external (non-LLM) runtime to its invocation.
|
|
154
|
+
* Returns { file, args } plus an optional `http` descriptor for the fetch path.
|
|
155
|
+
*/
|
|
156
|
+
export function buildInvocation(task, cwd, baseEnv) {
|
|
157
|
+
const type = normalizeTaskType(task.type);
|
|
158
|
+
const env = buildEnv(task, baseEnv);
|
|
159
|
+
switch (type) {
|
|
160
|
+
case 'node':
|
|
161
|
+
return { type, env, ...buildNodeInvocation(task, cwd) };
|
|
162
|
+
case 'python':
|
|
163
|
+
return { type, env, ...buildPythonInvocation(task, cwd) };
|
|
164
|
+
case 'http':
|
|
165
|
+
return { type, env, http: buildHttpRequest(task) };
|
|
166
|
+
case 'ssh':
|
|
167
|
+
return { type, env, ...buildSshInvocation(task) };
|
|
168
|
+
case 'docker':
|
|
169
|
+
return { type, env, ...buildDockerInvocation(task, env) };
|
|
170
|
+
case 'script':
|
|
171
|
+
default:
|
|
172
|
+
return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
|
|
173
|
+
}
|
|
174
|
+
}
|