@goodandready/dsh-cron 0.2.19 → 0.2.21
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-helpers.js +68 -0
- package/lib/api-import-export.js +145 -0
- package/lib/api-webhook.js +115 -0
- package/lib/api.js +24 -309
- package/lib/client.js +36 -3
- package/lib/index.js +25 -361
- package/lib/routes.js +194 -0
- package/lib/runner-external.js +238 -0
- package/lib/runner-worktree.js +55 -0
- package/lib/runner.js +40 -270
- package/lib/scheduler-execution.js +494 -0
- package/lib/scheduler-lifecycle.js +143 -0
- package/lib/scheduler.js +115 -625
- package/lib/settings.js +120 -0
- package/lib/task-create.js +60 -0
- package/package.json +3 -2
package/lib/routes.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createCronApiHandler } from './api.js';
|
|
3
|
+
import { createMetricsHandler } from './metrics.js';
|
|
4
|
+
import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
|
|
5
|
+
import { getModelsHandler } from './models-handler.js';
|
|
6
|
+
import { chatStartHandler } from './chat-start.js';
|
|
7
|
+
import { sendJson, rejectCrossOrigin, readBody, parseJsonBody } from './http-utils.js';
|
|
8
|
+
import { recipesByCategory } from './recipes.js';
|
|
9
|
+
import { sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
|
|
10
|
+
import { createKanbanCard } from './integrations.js';
|
|
11
|
+
import { sanitizeSettingsPayload, applySettingsToScope } from './settings.js';
|
|
12
|
+
|
|
13
|
+
export function registerRoutes(ctx, { store, scheduler, getCronSettingsScope, recommendations }) {
|
|
14
|
+
// handler per route family; registered WITHOUT the trailing slash (#96)
|
|
15
|
+
ctx.effect(() => ctx.webServer.register({
|
|
16
|
+
kind: 'prefix',
|
|
17
|
+
path: '/dsh-cron/tasks',
|
|
18
|
+
handler: createCronApiHandler(store, scheduler, { recommendations })
|
|
19
|
+
}), 'dsh-cron: /tasks[...]');
|
|
20
|
+
|
|
21
|
+
// Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
|
|
22
|
+
ctx.effect(() => ctx.webServer.register({
|
|
23
|
+
kind: 'prefix',
|
|
24
|
+
path: '/dsh-cron/action',
|
|
25
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
26
|
+
}), 'dsh-cron: /action/:id/:action');
|
|
27
|
+
|
|
28
|
+
ctx.effect(() => ctx.webServer.register({
|
|
29
|
+
kind: 'prefix',
|
|
30
|
+
path: '/dsh-cron/telegram',
|
|
31
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
32
|
+
}), 'dsh-cron: /telegram[...]');
|
|
33
|
+
|
|
34
|
+
ctx.effect(() => ctx.webServer.register({
|
|
35
|
+
kind: 'prefix',
|
|
36
|
+
path: '/dsh-cron/heartbeat-ping',
|
|
37
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
38
|
+
}), 'dsh-cron: /heartbeat-ping[...]');
|
|
39
|
+
|
|
40
|
+
ctx.effect(() => ctx.webServer.register({
|
|
41
|
+
kind: 'prefix',
|
|
42
|
+
path: '/dsh-cron/schedule',
|
|
43
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
44
|
+
}), 'dsh-cron: /schedule[...]');
|
|
45
|
+
|
|
46
|
+
// GET /dsh-cron/metrics (#53) — Prometheus text exposition
|
|
47
|
+
ctx.effect(() => ctx.webServer.register({
|
|
48
|
+
kind: 'exact',
|
|
49
|
+
path: '/dsh-cron/metrics',
|
|
50
|
+
handler: createMetricsHandler({ store, scheduler })
|
|
51
|
+
}), 'dsh-cron: /metrics');
|
|
52
|
+
|
|
53
|
+
// External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
|
|
54
|
+
// guarded by a bearer token, so CI and host automation can drive the
|
|
55
|
+
// scheduler without a browser session. The token is read per request, so a
|
|
56
|
+
// settings change applies without a restart.
|
|
57
|
+
ctx.effect(() => ctx.webServer.register({
|
|
58
|
+
kind: 'prefix',
|
|
59
|
+
path: EXTERNAL_API_PREFIX,
|
|
60
|
+
handler: createExternalApiHandler({
|
|
61
|
+
store,
|
|
62
|
+
scheduler,
|
|
63
|
+
getToken: () => store.getSettings().apiToken || ''
|
|
64
|
+
})
|
|
65
|
+
}), 'dsh-cron: /api[...]');
|
|
66
|
+
|
|
67
|
+
// GET /dsh-cron/models
|
|
68
|
+
ctx.effect(() => ctx.webServer.register({
|
|
69
|
+
kind: 'exact',
|
|
70
|
+
path: '/dsh-cron/models',
|
|
71
|
+
handler: async (req, res) => {
|
|
72
|
+
await getModelsHandler(ctx, req, res, sendJson);
|
|
73
|
+
}
|
|
74
|
+
}), 'dsh-cron: /models');
|
|
75
|
+
|
|
76
|
+
// POST /dsh-cron/chat/start
|
|
77
|
+
ctx.effect(() => ctx.webServer.register({
|
|
78
|
+
kind: 'exact',
|
|
79
|
+
path: '/dsh-cron/chat/start',
|
|
80
|
+
handler: async (req, res) => {
|
|
81
|
+
if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
|
|
82
|
+
await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
|
|
83
|
+
}
|
|
84
|
+
}), 'dsh-cron: /chat/start');
|
|
85
|
+
|
|
86
|
+
// GET /dsh-cron/recipes (#48): the built-in recipe catalog.
|
|
87
|
+
ctx.effect(() => ctx.webServer.register({
|
|
88
|
+
kind: 'exact',
|
|
89
|
+
path: '/dsh-cron/recipes',
|
|
90
|
+
handler: async (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
if (req.method !== 'GET') {
|
|
93
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const categories = recipesByCategory();
|
|
97
|
+
const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
|
|
98
|
+
sendJson(res, 200, { ok: true, categories, recipes });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}), 'dsh-cron: /recipes');
|
|
104
|
+
|
|
105
|
+
// GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
|
|
106
|
+
ctx.effect(() => ctx.webServer.register({
|
|
107
|
+
kind: 'exact',
|
|
108
|
+
path: '/dsh-cron/heartbeat',
|
|
109
|
+
handler: async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const tasks = store.list({ status: 'active' });
|
|
112
|
+
const lastRuns = tasks.map((t) => t.lastRunAt || 0);
|
|
113
|
+
sendJson(res, 200, {
|
|
114
|
+
ok: true,
|
|
115
|
+
activeTasks: tasks.length,
|
|
116
|
+
running: scheduler.runningCount(),
|
|
117
|
+
lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
|
|
118
|
+
serverTime: Date.now(),
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}), 'dsh-cron: /heartbeat');
|
|
125
|
+
|
|
126
|
+
// POST /dsh-cron/telegram/test
|
|
127
|
+
ctx.effect(() => ctx.webServer.register({
|
|
128
|
+
kind: 'exact',
|
|
129
|
+
path: '/dsh-cron/telegram/test',
|
|
130
|
+
handler: async (req, res) => {
|
|
131
|
+
try {
|
|
132
|
+
if (req.method !== 'POST') {
|
|
133
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (rejectCrossOrigin(req, res)) return;
|
|
137
|
+
const { body, error } = await readBody(req, res);
|
|
138
|
+
if (error) return;
|
|
139
|
+
const settings = store.getSettings();
|
|
140
|
+
const botToken = body.botToken || settings.botToken;
|
|
141
|
+
const chatId = body.chatId || settings.chatId;
|
|
142
|
+
|
|
143
|
+
if (!botToken || !chatId) {
|
|
144
|
+
sendJson(res, 400, { ok: false, error: 'botToken or chatId is not configured' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
|
|
149
|
+
const replyMarkup = createTelegramTaskKeyboard(undefined, undefined);
|
|
150
|
+
await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
|
|
151
|
+
sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}), 'dsh-cron: /telegram/test');
|
|
157
|
+
|
|
158
|
+
// POST /dsh-cron/kanban/test
|
|
159
|
+
ctx.effect(() => ctx.webServer.register({
|
|
160
|
+
kind: 'exact',
|
|
161
|
+
path: '/dsh-cron/kanban/test',
|
|
162
|
+
handler: async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
if (req.method !== 'POST') {
|
|
165
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (rejectCrossOrigin(req, res)) return;
|
|
169
|
+
const { body, error } = await readBody(req, res);
|
|
170
|
+
if (error) return;
|
|
171
|
+
const settings = store.getSettings();
|
|
172
|
+
const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
|
|
173
|
+
|
|
174
|
+
const result = await createKanbanCard({
|
|
175
|
+
title: '[Test] dsh-cron -> Kanban connectivity check',
|
|
176
|
+
body: `Test card created automatically by the \`dsh-cron\` module at ${new Date().toISOString()}.`,
|
|
177
|
+
board: 'main',
|
|
178
|
+
column: 'backlog',
|
|
179
|
+
labels: ['cron', 'test'],
|
|
180
|
+
kanbanBaseUrl
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (result.success) {
|
|
184
|
+
sendJson(res, 200, { ok: true, task: result.task });
|
|
185
|
+
} else {
|
|
186
|
+
sendJson(res, 500, { ok: false, error: result.error });
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}), 'dsh-cron: /kanban/test');
|
|
193
|
+
}
|
|
194
|
+
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { bestEffort } from './best-effort.js';
|
|
2
|
+
import {
|
|
3
|
+
buildInvocation,
|
|
4
|
+
} from './runtimes.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper to detect context window overflow errors in persistent sessions (#145).
|
|
8
|
+
*/
|
|
9
|
+
export function isContextOverflowError(err) {
|
|
10
|
+
const msg = String(err?.message || err || '').toLowerCase();
|
|
11
|
+
return msg.includes('context length') ||
|
|
12
|
+
msg.includes('context window') ||
|
|
13
|
+
msg.includes('maximum context') ||
|
|
14
|
+
msg.includes('too many tokens') ||
|
|
15
|
+
msg.includes('prompt too long') ||
|
|
16
|
+
msg.includes('token limit') ||
|
|
17
|
+
msg.includes('context_length_exceeded') ||
|
|
18
|
+
msg.includes('string_above_max_length');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Cross-platform process tree killer (#145).
|
|
23
|
+
* On Windows, calls `taskkill /pid <pid> /T /F` to terminate the entire process hierarchy.
|
|
24
|
+
*/
|
|
25
|
+
export function terminateProcessTree(child, sig = 'SIGTERM', { isPosix = process.platform !== 'win32', execFn } = {}) {
|
|
26
|
+
if (!child || child.killed) return;
|
|
27
|
+
bestEffort('terminate-process-tree', () => {
|
|
28
|
+
if (isPosix && child.pid) {
|
|
29
|
+
bestEffort('kill-posix-pid', () => process.kill(-child.pid, sig));
|
|
30
|
+
} else if (child.pid) {
|
|
31
|
+
const runner = execFn || ((cmd) => {
|
|
32
|
+
bestEffort('exec-taskkill-fn', () => {
|
|
33
|
+
import('node:child_process').then(({ exec }) => {
|
|
34
|
+
exec(cmd, { windowsHide: true }, () => {});
|
|
35
|
+
}).catch(() => {});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
bestEffort('exec-taskkill', () => runner(`taskkill /pid ${child.pid} /T /F`));
|
|
39
|
+
bestEffort('child-kill', () => child.kill(sig));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Helper to test if an error code or status is transient and eligible for retry (#134).
|
|
46
|
+
*/
|
|
47
|
+
export function isTransientError(errOrStatus) {
|
|
48
|
+
if (typeof errOrStatus === 'number') {
|
|
49
|
+
return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
|
|
50
|
+
}
|
|
51
|
+
const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
|
|
52
|
+
return str.includes('429') || str.includes('too many requests') ||
|
|
53
|
+
str.includes('502') || str.includes('bad gateway') ||
|
|
54
|
+
str.includes('503') || str.includes('service unavailable') ||
|
|
55
|
+
str.includes('504') || str.includes('gateway timeout') ||
|
|
56
|
+
str.includes('econnreset') || str.includes('etimedout');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** HTTP trigger runtime (#7) with transient retry (#134). */
|
|
60
|
+
export async function runHttp(task, request, signal) {
|
|
61
|
+
let attempts = 0;
|
|
62
|
+
const maxAttempts = 3;
|
|
63
|
+
while (attempts < maxAttempts) {
|
|
64
|
+
attempts++;
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(request.url, {
|
|
67
|
+
method: request.method,
|
|
68
|
+
headers: request.headers,
|
|
69
|
+
body: request.body,
|
|
70
|
+
signal,
|
|
71
|
+
});
|
|
72
|
+
const text = await res.text().catch(() => '');
|
|
73
|
+
const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
|
|
76
|
+
if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
|
|
77
|
+
console.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
78
|
+
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
85
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
86
|
+
costUsd: 0,
|
|
87
|
+
};
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
|
|
90
|
+
console.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
91
|
+
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* #9 reuse-first path: when the task names a dsh-remote-workspace profile,
|
|
101
|
+
* execute through that plugin's `remoteSsh` service so host settings and
|
|
102
|
+
* credentials stay owned by it (we only reference the profile id).
|
|
103
|
+
* Returns null when the service or the profile is unavailable so the caller
|
|
104
|
+
* can fall back to a direct ssh invocation.
|
|
105
|
+
*/
|
|
106
|
+
export async function runViaRemoteWorkspace(task, signal, ctx) {
|
|
107
|
+
if (!task.sshProfileId || !ctx || typeof ctx.get !== 'function') return null;
|
|
108
|
+
let svc = null;
|
|
109
|
+
svc = bestEffort('get-remoteSsh', () => ctx.get('remoteSsh'));
|
|
110
|
+
if (!svc || typeof svc.exec !== 'function') return null;
|
|
111
|
+
|
|
112
|
+
let profile = null;
|
|
113
|
+
profile = bestEffort('get-remoteSsh-profile', () => {
|
|
114
|
+
const settings = ctx.get('settings');
|
|
115
|
+
if (settings && typeof settings.get === 'function') {
|
|
116
|
+
const snap = settings.get('dsh-remote-workspace');
|
|
117
|
+
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
118
|
+
return profiles.find((p) => p && p.id === task.sshProfileId) || null;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
});
|
|
122
|
+
if (!profile) {
|
|
123
|
+
throw new Error(`remote-workspace profile "${task.sshProfileId}" was not found — check dsh-remote-workspace settings, or use sshTarget instead`);
|
|
124
|
+
}
|
|
125
|
+
const res = await svc.exec(profile, String(task.prompt || 'true'), task.cwd || profile.remoteWorkspace || undefined);
|
|
126
|
+
const output = [res && res.stdout, res && res.stderr].filter((x) => x && String(x).trim()).join('\n').trim();
|
|
127
|
+
const code = res && typeof res.code === 'number' ? res.code : 0;
|
|
128
|
+
if (code !== 0) {
|
|
129
|
+
throw new Error(`Remote command exited with code ${code}\n${output}`.slice(0, 4000));
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
output: output || 'Remote command completed with code 0',
|
|
133
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
134
|
+
costUsd: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
|
|
139
|
+
export async function runExternal(task, runtime, signal, execOptions = {}, { ctx = null, resolveCwdFn = null } = {}) {
|
|
140
|
+
if (runtime === 'ssh' && task.sshProfileId) {
|
|
141
|
+
const viaService = await runViaRemoteWorkspace(task, signal, ctx);
|
|
142
|
+
if (viaService) return viaService;
|
|
143
|
+
if (!task.sshTarget) {
|
|
144
|
+
throw new Error('SSH task needs either a working dsh-remote-workspace profile (sshProfileId) or an sshTarget');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const cwd = resolveCwdFn ? resolveCwdFn(task) : (task.cwd || process.cwd());
|
|
148
|
+
const invocation = buildInvocation({ ...task, type: runtime }, cwd);
|
|
149
|
+
if (invocation.http) return runHttp(task, invocation.http, signal);
|
|
150
|
+
|
|
151
|
+
const { spawn, exec } = await import('node:child_process');
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
let child = null;
|
|
154
|
+
let settled = false;
|
|
155
|
+
let stdout = '';
|
|
156
|
+
let stderr = '';
|
|
157
|
+
const maxBuffer = 10 * 1024 * 1024;
|
|
158
|
+
const isPosix = process.platform !== 'win32';
|
|
159
|
+
|
|
160
|
+
const killProcessTree = (sig = 'SIGTERM') => {
|
|
161
|
+
terminateProcessTree(child, sig, {
|
|
162
|
+
isPosix,
|
|
163
|
+
execFn: (cmd) => {
|
|
164
|
+
bestEffort('exec-cmd', () => exec(cmd, { windowsHide: true }, () => {}));
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const abortHandler = () => {
|
|
170
|
+
killProcessTree('SIGTERM');
|
|
171
|
+
setTimeout(() => killProcessTree('SIGKILL'), 300);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (signal) {
|
|
175
|
+
if (signal.aborted) {
|
|
176
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
177
|
+
}
|
|
178
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const runEnv = { ...invocation.env };
|
|
182
|
+
if (execOptions?.prevOutput !== undefined) {
|
|
183
|
+
runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
|
|
184
|
+
}
|
|
185
|
+
const options = {
|
|
186
|
+
cwd,
|
|
187
|
+
env: runEnv,
|
|
188
|
+
detached: isPosix,
|
|
189
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (invocation.file) {
|
|
193
|
+
child = spawn(invocation.file, invocation.args || [], options);
|
|
194
|
+
} else {
|
|
195
|
+
const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
|
|
196
|
+
const shellArgs = isPosix ? ['-c', invocation.shellCommand] : ['/d', '/s', '/c', invocation.shellCommand];
|
|
197
|
+
child = spawn(shellPath, shellArgs, options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
child.stdout.on('data', (chunk) => {
|
|
201
|
+
if (stdout.length < maxBuffer) stdout += chunk;
|
|
202
|
+
});
|
|
203
|
+
child.stderr.on('data', (chunk) => {
|
|
204
|
+
if (stderr.length < maxBuffer) stderr += chunk;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
child.on('error', (err) => {
|
|
208
|
+
if (settled) return;
|
|
209
|
+
settled = true;
|
|
210
|
+
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
211
|
+
reject(err);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
child.on('close', (code, sig) => {
|
|
215
|
+
if (settled) return;
|
|
216
|
+
settled = true;
|
|
217
|
+
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
218
|
+
|
|
219
|
+
if (signal && signal.aborted) {
|
|
220
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (code !== 0 && code !== null) {
|
|
224
|
+
const detail = String(stderr || '').trim();
|
|
225
|
+
const msg = `${runtime} task exited with code ${code}`;
|
|
226
|
+
return reject(new Error(detail ? `${msg}\n${detail}` : msg));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const out = (stdout || stderr || '').trim();
|
|
230
|
+
resolve({
|
|
231
|
+
output: out || `${runtime} task completed with code 0`,
|
|
232
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
233
|
+
costUsd: 0,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { bestEffort } from './best-effort.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the working directory for a task: explicit cwd, a bound
|
|
5
|
+
* workspace id resolved through the harness, or the process cwd (#31).
|
|
6
|
+
*/
|
|
7
|
+
export function resolveCwd(task, ctx = null) {
|
|
8
|
+
if (task.cwd) return String(task.cwd);
|
|
9
|
+
if (task.workspaceId && ctx && typeof ctx.get === 'function') {
|
|
10
|
+
const resolved = bestEffort('resolve-workspace-path', () => {
|
|
11
|
+
const workspaces = ctx.get('workspaces');
|
|
12
|
+
if (workspaces && typeof workspaces.get === 'function') {
|
|
13
|
+
const ws = workspaces.get(task.workspaceId);
|
|
14
|
+
return ws && ws.path ? String(ws.path) : null;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
});
|
|
18
|
+
if (resolved) return resolved;
|
|
19
|
+
}
|
|
20
|
+
return process.cwd();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create an isolated git worktree for a run when task.worktree is requested (#29).
|
|
25
|
+
* Returns the directory path of the worktree.
|
|
26
|
+
*/
|
|
27
|
+
export async function createTaskWorktree(cwd, task) {
|
|
28
|
+
const { execFile } = await import('node:child_process');
|
|
29
|
+
const { randomUUID } = await import('node:crypto');
|
|
30
|
+
const branch = `cron-${task.id}-${randomUUID().slice(0, 8)}`;
|
|
31
|
+
const wtPath = `${cwd}/.worktrees/${branch}`;
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
execFile('git', ['-C', cwd, 'worktree', 'add', '-b', branch, wtPath, 'HEAD'], (err) => {
|
|
34
|
+
if (err) return reject(new Error(`Failed to create worktree: ${err.message}`));
|
|
35
|
+
resolve(wtPath);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Clean up the isolated worktree after execution unless keepWorktree is set (#29).
|
|
42
|
+
* Uses best-effort removal without forcing, followed by git worktree prune.
|
|
43
|
+
*/
|
|
44
|
+
export async function removeTaskWorktree(cwd, wtPath) {
|
|
45
|
+
const { execFile } = await import('node:child_process');
|
|
46
|
+
await new Promise((resolve) => {
|
|
47
|
+
execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
48
|
+
if (err) {
|
|
49
|
+
console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
|
|
50
|
+
}
|
|
51
|
+
execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|