@aion0/forge 0.9.1 → 0.9.3
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/RELEASE_NOTES.md +5 -5
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +106 -0
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/telegram-bot.ts +9 -3
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- package/lib/help-docs/22-recipes.md +0 -124
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules action runner — fires the schedule's action against the
|
|
3
|
+
* body_output once the body settles.
|
|
4
|
+
*
|
|
5
|
+
* Phase 4 ships action.kind='chat' only. email + telegram land in
|
|
6
|
+
* phases 5-6. action_kind='none' is the default no-op.
|
|
7
|
+
*
|
|
8
|
+
* Wired into every body-completion site:
|
|
9
|
+
* - pipeline-scheduler.syncRunStatus (pipeline body done)
|
|
10
|
+
* - scheduler.settleSkillRunIfMatch (skill body done)
|
|
11
|
+
* - scheduler.invokeConnectorTool (connector_tool done)
|
|
12
|
+
* - store.reconcileStaleScheduleRuns (forge-restart catch-up)
|
|
13
|
+
*
|
|
14
|
+
* Action runs at-most-once per run (we look at action_status='pending'/
|
|
15
|
+
* null and set to 'done'/'failed'/'skipped' atomically).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getDb } from '@/src/core/db/database';
|
|
19
|
+
import { getDbPath } from '@/src/config';
|
|
20
|
+
import { getScheduleRun, getSchedule, setScheduleRunAction } from './store';
|
|
21
|
+
import { appendMessage, getSession } from '@/lib/chat/session-store';
|
|
22
|
+
import { loadSettings } from '@/lib/settings';
|
|
23
|
+
import type { Schedule, ScheduleRun, ScheduleActionStatus } from './types';
|
|
24
|
+
|
|
25
|
+
function db() { return getDb(getDbPath()); }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run the action for a settled schedule_run by target_id (the most
|
|
29
|
+
* common entry point — callers know target_id, not run_id). Looks up
|
|
30
|
+
* the latest non-pending row for that target. Safe to call multiple
|
|
31
|
+
* times; idempotent against `action_status`.
|
|
32
|
+
*/
|
|
33
|
+
export async function runActionForTarget(targetId: string): Promise<void> {
|
|
34
|
+
const row = db().prepare(
|
|
35
|
+
`SELECT id FROM schedule_runs WHERE target_id = ?
|
|
36
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
37
|
+
).get(targetId) as { id: string } | undefined;
|
|
38
|
+
if (!row) return;
|
|
39
|
+
await runAction(row.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run the action against a specific schedule_run. */
|
|
43
|
+
export async function runAction(runId: string): Promise<void> {
|
|
44
|
+
const run = getScheduleRun(runId);
|
|
45
|
+
if (!run) return;
|
|
46
|
+
if (run.action_status !== null) return; // already settled
|
|
47
|
+
|
|
48
|
+
const schedule = getSchedule(run.schedule_id);
|
|
49
|
+
if (!schedule) {
|
|
50
|
+
markAction(runId, 'failed', 'schedule gone');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (schedule.action_kind === 'none') {
|
|
55
|
+
markAction(runId, 'done', null);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Body must have completed successfully for action to fire.
|
|
60
|
+
// 'failed' / 'cancelled' body: skip action (downstream chat/email
|
|
61
|
+
// shouldn't get a half-baked output).
|
|
62
|
+
if (run.status !== 'done') {
|
|
63
|
+
markAction(runId, 'skipped', `body status was ${run.status}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const output = (run.body_output || '').trim();
|
|
68
|
+
if (!output && schedule.action_skip_on_empty) {
|
|
69
|
+
markAction(runId, 'skipped', 'body produced no output and action_skip_on_empty=true');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
if (schedule.action_kind === 'chat') {
|
|
75
|
+
await runChatAction(schedule, run, output);
|
|
76
|
+
markAction(runId, 'done', null);
|
|
77
|
+
} else if (schedule.action_kind === 'email') {
|
|
78
|
+
await runEmailAction(schedule, run, output);
|
|
79
|
+
markAction(runId, 'done', null);
|
|
80
|
+
} else if (schedule.action_kind === 'telegram') {
|
|
81
|
+
await runTelegramAction(schedule, run, output);
|
|
82
|
+
markAction(runId, 'done', null);
|
|
83
|
+
} else {
|
|
84
|
+
markAction(runId, 'failed', `unknown action_kind '${schedule.action_kind}'`);
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const err = e as Error;
|
|
88
|
+
console.error(`[schedules:action] run ${runId} ${schedule.action_kind} failed:`, err);
|
|
89
|
+
markAction(runId, 'failed', err.message || String(e));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function markAction(runId: string, status: ScheduleActionStatus, error: string | null): void {
|
|
94
|
+
setScheduleRunAction({ run_id: runId, status, error });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── chat action ──────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function runChatAction(schedule: Schedule, run: ScheduleRun, output: string): Promise<void> {
|
|
100
|
+
const cfg = schedule.action_config || {};
|
|
101
|
+
const sessionId = typeof cfg.session_id === 'string' ? cfg.session_id.trim() : '';
|
|
102
|
+
if (!sessionId) {
|
|
103
|
+
throw new Error('chat action requires action_config.session_id');
|
|
104
|
+
}
|
|
105
|
+
const session = getSession(sessionId);
|
|
106
|
+
if (!session) {
|
|
107
|
+
throw new Error(`chat session "${sessionId}" not found`);
|
|
108
|
+
}
|
|
109
|
+
const prefix = typeof cfg.prefix === 'string' ? cfg.prefix : '';
|
|
110
|
+
const header = `📋 Schedule "${schedule.name}" — ${new Date(run.started_at).toLocaleString()}`;
|
|
111
|
+
const text = prefix
|
|
112
|
+
? `${header}\n\n${prefix}${output}`
|
|
113
|
+
: `${header}\n\n${output}`;
|
|
114
|
+
|
|
115
|
+
// Route through chat-standalone so an open chat tab gets the SSE
|
|
116
|
+
// `message_saved` push. Writing the row directly via appendMessage
|
|
117
|
+
// works for persistence but the tab only sees the message after a
|
|
118
|
+
// reload. Fall back to direct write if the standalone is down.
|
|
119
|
+
const chatPort = Number(process.env.CHAT_PORT) || 8408;
|
|
120
|
+
const payload = JSON.stringify({ role: 'assistant', blocks: [{ type: 'text', text }] });
|
|
121
|
+
try {
|
|
122
|
+
const r = await fetch(`http://127.0.0.1:${chatPort}/api/sessions/${encodeURIComponent(sessionId)}/inject`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: payload,
|
|
126
|
+
});
|
|
127
|
+
if (!r.ok) throw new Error(`inject HTTP ${r.status}`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.warn(`[schedules:chat] inject via standalone failed (${(e as Error).message}); falling back to direct DB write`);
|
|
130
|
+
appendMessage({
|
|
131
|
+
session_id: sessionId,
|
|
132
|
+
role: 'assistant',
|
|
133
|
+
blocks: [{ type: 'text', text }],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── email action ─────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Email action via nodemailer. SMTP transport is configured globally
|
|
142
|
+
* in settings.smtp* (host/port/secure/user/password/from). Each
|
|
143
|
+
* schedule supplies the recipient + templates in action_config:
|
|
144
|
+
*
|
|
145
|
+
* {
|
|
146
|
+
* "to": "alice@example.com" | ["a@..", "b@.."],
|
|
147
|
+
* "subject_template": "Daily bug list — {date}",
|
|
148
|
+
* "body_template": "{body_output}",
|
|
149
|
+
* "html": false // (optional) treat body as HTML
|
|
150
|
+
* }
|
|
151
|
+
*
|
|
152
|
+
* Templates support two placeholders for now:
|
|
153
|
+
* {date} — current date (YYYY-MM-DD)
|
|
154
|
+
* {body_output} — captured body output
|
|
155
|
+
*/
|
|
156
|
+
async function runEmailAction(schedule: Schedule, _run: ScheduleRun, output: string): Promise<void> {
|
|
157
|
+
const settings = loadSettings();
|
|
158
|
+
if (!settings.smtpHost) {
|
|
159
|
+
throw new Error('SMTP not configured (Settings → SMTP)');
|
|
160
|
+
}
|
|
161
|
+
const cfg = schedule.action_config || {};
|
|
162
|
+
const toRaw = cfg.to;
|
|
163
|
+
const to = Array.isArray(toRaw)
|
|
164
|
+
? (toRaw as unknown[]).filter((x) => typeof x === 'string' && x.trim()).map((x) => String(x).trim())
|
|
165
|
+
: (typeof toRaw === 'string' && toRaw.trim() ? [toRaw.trim()] : []);
|
|
166
|
+
if (to.length === 0) {
|
|
167
|
+
throw new Error('email action requires action_config.to (string or string[])');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
171
|
+
const subjectTpl = typeof cfg.subject_template === 'string' && cfg.subject_template
|
|
172
|
+
? cfg.subject_template
|
|
173
|
+
: `Forge schedule: ${schedule.name}`;
|
|
174
|
+
const bodyTpl = typeof cfg.body_template === 'string' && cfg.body_template
|
|
175
|
+
? cfg.body_template
|
|
176
|
+
: '{body_output}';
|
|
177
|
+
const subject = subjectTpl.replace('{date}', today).replace('{body_output}', output);
|
|
178
|
+
const bodyText = bodyTpl.replace('{date}', today).replace('{body_output}', output);
|
|
179
|
+
const asHtml = !!cfg.html;
|
|
180
|
+
|
|
181
|
+
// Lazy import keeps the SMTP client out of the main bundle until the
|
|
182
|
+
// first email action actually fires.
|
|
183
|
+
const nodemailer = (await import('nodemailer')).default;
|
|
184
|
+
const transport = nodemailer.createTransport({
|
|
185
|
+
host: settings.smtpHost,
|
|
186
|
+
port: settings.smtpPort,
|
|
187
|
+
secure: !!settings.smtpSecure,
|
|
188
|
+
auth: settings.smtpUser
|
|
189
|
+
? { user: settings.smtpUser, pass: settings.smtpPassword }
|
|
190
|
+
: undefined,
|
|
191
|
+
} as any);
|
|
192
|
+
const from = settings.smtpFrom || (settings.smtpUser ? settings.smtpUser : '');
|
|
193
|
+
if (!from) throw new Error('SMTP from address required (Settings → SMTP → from or user)');
|
|
194
|
+
await transport.sendMail({
|
|
195
|
+
from,
|
|
196
|
+
to,
|
|
197
|
+
subject,
|
|
198
|
+
[asHtml ? 'html' : 'text']: bodyText,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── telegram action ──────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Telegram action — posts body_output via the same bot Forge already
|
|
206
|
+
* uses for task notifications. Reuses settings.telegramBotToken; chat
|
|
207
|
+
* id falls back to settings.telegramChatId so a user can just toggle
|
|
208
|
+
* the radio without per-schedule config.
|
|
209
|
+
*
|
|
210
|
+
* action_config:
|
|
211
|
+
* chat_id string (optional, overrides settings.telegramChatId)
|
|
212
|
+
* prefix string (optional, prepended to body_output)
|
|
213
|
+
* parse_mode "Markdown" | "MarkdownV2" | "HTML" | "" (default Markdown)
|
|
214
|
+
*/
|
|
215
|
+
async function runTelegramAction(schedule: Schedule, run: ScheduleRun, output: string): Promise<void> {
|
|
216
|
+
const settings = loadSettings();
|
|
217
|
+
if (!settings.telegramBotToken) {
|
|
218
|
+
throw new Error('telegramBotToken not configured (Settings → Telegram)');
|
|
219
|
+
}
|
|
220
|
+
const cfg = schedule.action_config || {};
|
|
221
|
+
const chatId = typeof cfg.chat_id === 'string' && cfg.chat_id.trim()
|
|
222
|
+
? cfg.chat_id.trim()
|
|
223
|
+
: settings.telegramChatId;
|
|
224
|
+
if (!chatId) {
|
|
225
|
+
throw new Error('telegram action requires action_config.chat_id (or settings.telegramChatId)');
|
|
226
|
+
}
|
|
227
|
+
const prefix = typeof cfg.prefix === 'string' ? cfg.prefix : '';
|
|
228
|
+
const parseMode = typeof cfg.parse_mode === 'string' ? cfg.parse_mode : 'Markdown';
|
|
229
|
+
const header = `📋 *${escapeTelegram(schedule.name)}* — ${new Date(run.started_at).toLocaleString()}`;
|
|
230
|
+
const text = prefix
|
|
231
|
+
? `${header}\n\n${prefix}${output}`
|
|
232
|
+
: `${header}\n\n${output}`;
|
|
233
|
+
|
|
234
|
+
const url = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage`;
|
|
235
|
+
const body: Record<string, unknown> = {
|
|
236
|
+
chat_id: chatId,
|
|
237
|
+
text,
|
|
238
|
+
disable_web_page_preview: true,
|
|
239
|
+
};
|
|
240
|
+
if (parseMode) body.parse_mode = parseMode;
|
|
241
|
+
const res = await fetch(url, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
244
|
+
body: JSON.stringify(body),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
const errText = await res.text().catch(() => '');
|
|
248
|
+
throw new Error(`telegram sendMessage failed: ${res.status} ${errText.slice(0, 200)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Escape underscores / asterisks in the schedule name so Markdown
|
|
253
|
+
* doesn't try to render them. parse_mode='Markdown' is lenient but
|
|
254
|
+
* unbalanced `_` causes a 400 from the API. */
|
|
255
|
+
function escapeTelegram(s: string): string {
|
|
256
|
+
return s.replace(/([_*`\[])/g, '\\$1');
|
|
257
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules scheduler — 60s tick loop parallel to the Job scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Each tick:
|
|
5
|
+
* 1. reconcile stale schedule_runs (zombie cleanup)
|
|
6
|
+
* 2. for each due, non-manual schedule:
|
|
7
|
+
* - advance next_run_at (period / once / cron)
|
|
8
|
+
* - skip if it already has inflight body
|
|
9
|
+
* - dispatch body (pipeline / skill / connector_tool — phase-gated)
|
|
10
|
+
* - insert schedule_run row
|
|
11
|
+
*
|
|
12
|
+
* No sequential / on_failure / dedup / retry knobs. All body-internal.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
17
|
+
import { startPipeline, getWorkflow } from '@/lib/pipeline';
|
|
18
|
+
import {
|
|
19
|
+
createTask,
|
|
20
|
+
getTask,
|
|
21
|
+
onTaskEvent,
|
|
22
|
+
taskAppendSystemPromptOverrides,
|
|
23
|
+
} from '@/lib/task-manager';
|
|
24
|
+
import { getProjectInfo } from '@/lib/projects';
|
|
25
|
+
import { dispatchTool } from '@/lib/chat/tool-dispatcher';
|
|
26
|
+
import { ensureInstalledInProject } from '@/lib/skills';
|
|
27
|
+
import { getDb } from '@/src/core/db/database';
|
|
28
|
+
import { getDbPath } from '@/src/config';
|
|
29
|
+
import {
|
|
30
|
+
ensureSchema,
|
|
31
|
+
listDueSchedules,
|
|
32
|
+
countInflightForSchedule,
|
|
33
|
+
setNextRunAt,
|
|
34
|
+
setLastRunAt,
|
|
35
|
+
insertScheduleRun,
|
|
36
|
+
reconcileStaleScheduleRuns,
|
|
37
|
+
updateSchedule,
|
|
38
|
+
updateScheduleRunStatus,
|
|
39
|
+
setScheduleRunBodyOutputByTarget,
|
|
40
|
+
getSchedule,
|
|
41
|
+
} from './store';
|
|
42
|
+
import { runActionForTarget } from './action-runner';
|
|
43
|
+
import type { Schedule, ScheduleRunTrigger, ScheduleRunStatus } from './types';
|
|
44
|
+
|
|
45
|
+
const TICK_INTERVAL_MS = 60_000;
|
|
46
|
+
const schedulerKey = Symbol.for('forge-schedules-scheduler');
|
|
47
|
+
|
|
48
|
+
export function startSchedulesScheduler(): void {
|
|
49
|
+
const g = globalThis as any;
|
|
50
|
+
if (g[schedulerKey]) return;
|
|
51
|
+
g[schedulerKey] = true;
|
|
52
|
+
ensureSchema();
|
|
53
|
+
installSkillTaskListener();
|
|
54
|
+
console.log('[schedules-scheduler] started');
|
|
55
|
+
void tick();
|
|
56
|
+
setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function tick(): Promise<void> {
|
|
60
|
+
try {
|
|
61
|
+
reconcileStaleScheduleRuns();
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.warn(`[schedules-scheduler] reconcile failed: ${(e as Error).message}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let due: Schedule[] = [];
|
|
67
|
+
try { due = listDueSchedules(); }
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.error('[schedules-scheduler] listDue failed', e);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const s of due) {
|
|
74
|
+
try {
|
|
75
|
+
advanceSchedule(s);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.warn(`[schedules-scheduler] advanceSchedule ${s.id} failed: ${(e as Error).message}`);
|
|
78
|
+
}
|
|
79
|
+
if (countInflightForSchedule(s.id) > 0) {
|
|
80
|
+
// Prior body still running — skip this tick (will retry next minute).
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
void executeSchedule(s, 'schedule').catch((err) => {
|
|
84
|
+
console.error(`[schedules-scheduler] execute ${s.id} crashed`, err);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toSqlIso(d: Date): string {
|
|
90
|
+
return d.toISOString().replace('T', ' ').slice(0, 19);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compute the next_run_at after this tick fires. Behaviour mirrors
|
|
95
|
+
* the Job scheduler's advanceSchedule so users see consistent semantics.
|
|
96
|
+
*/
|
|
97
|
+
export function advanceSchedule(s: Schedule): void {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
|
|
100
|
+
if (s.schedule_kind === 'manual') {
|
|
101
|
+
// Should never reach here (listDueSchedules excludes manual), but be safe.
|
|
102
|
+
setNextRunAt(s.id, null);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (s.schedule_kind === 'once') {
|
|
107
|
+
// One-shot: this tick fires, then auto-disable.
|
|
108
|
+
setNextRunAt(s.id, null);
|
|
109
|
+
try { updateSchedule(s.id, { enabled: false }); } catch (e) {
|
|
110
|
+
console.warn(`[schedules-scheduler] auto-disable once schedule ${s.id} failed:`, e);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (s.schedule_kind === 'cron' && s.schedule_cron) {
|
|
116
|
+
try {
|
|
117
|
+
const iter = CronExpressionParser.parse(s.schedule_cron, { currentDate: new Date(now) });
|
|
118
|
+
const next = iter.next().toDate();
|
|
119
|
+
setNextRunAt(s.id, toSqlIso(next));
|
|
120
|
+
return;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.warn(`[schedules-scheduler] cron parse failed for ${s.id} ("${s.schedule_cron}"):`, (e as Error).message);
|
|
123
|
+
// Fall through to period default so a broken cron doesn't tight-loop.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Default / 'period' path.
|
|
128
|
+
const minutes = Math.max(1, s.schedule_interval_minutes || 30);
|
|
129
|
+
const next = new Date(now + minutes * 60_000);
|
|
130
|
+
setNextRunAt(s.id, toSqlIso(next));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Launch one body run for this schedule. Used by the tick loop AND
|
|
135
|
+
* the manual fire API. Returns the target_id (the dispatched body's id).
|
|
136
|
+
*/
|
|
137
|
+
export async function executeSchedule(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
|
|
138
|
+
if (s.body_kind === 'pipeline') return executePipelineBody(s, trigger);
|
|
139
|
+
if (s.body_kind === 'skill') return executeSkillBody(s, trigger);
|
|
140
|
+
if (s.body_kind === 'connector_tool') return executeConnectorToolBody(s, trigger);
|
|
141
|
+
throw new Error(`unknown body_kind: ${s.body_kind}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── pipeline body ────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function executePipelineBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
|
|
147
|
+
// Coerce input values to strings for the pipeline orchestrator.
|
|
148
|
+
// Schedule.input is typed `Record<string, unknown>` because the UI form
|
|
149
|
+
// can save anything (numbers, booleans, nested), but startPipeline
|
|
150
|
+
// currently expects `Record<string, string>`. Stringify non-strings.
|
|
151
|
+
const stringInput: Record<string, string> = {};
|
|
152
|
+
for (const [k, v] of Object.entries(s.input || {})) {
|
|
153
|
+
if (v == null) { stringInput[k] = ''; continue; }
|
|
154
|
+
stringInput[k] = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
155
|
+
}
|
|
156
|
+
// Pre-install skills into every project the workflow's nodes target,
|
|
157
|
+
// mirroring Job's dispatcher (lib/jobs/scheduler.ts ~563). Without
|
|
158
|
+
// this Claude's `/skill-name` invocations fail because the skill
|
|
159
|
+
// SKILL.md isn't on disk under the project's .claude/skills/.
|
|
160
|
+
await preinstallSkillsForPipeline(s.body_ref, stringInput, s.skills);
|
|
161
|
+
const pipeline = startPipeline(s.body_ref, stringInput, { skills: s.skills });
|
|
162
|
+
insertScheduleRun({ schedule_id: s.id, target_id: pipeline.id, trigger });
|
|
163
|
+
setLastRunAt(s.id, toSqlIso(new Date()));
|
|
164
|
+
return pipeline.id;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** For each unique project name referenced by the workflow's nodes,
|
|
168
|
+
* resolve it (cheap `{{input.X}}` expansion) and install every skill
|
|
169
|
+
* there. Idempotent — `ensureInstalledInProject` no-ops if the skill
|
|
170
|
+
* is already at the right version. Errors are warned, not thrown. */
|
|
171
|
+
async function preinstallSkillsForPipeline(
|
|
172
|
+
workflowName: string,
|
|
173
|
+
input: Record<string, string>,
|
|
174
|
+
skills: string[],
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
if (!skills || skills.length === 0) return;
|
|
177
|
+
const workflow = getWorkflow(workflowName);
|
|
178
|
+
if (!workflow) return;
|
|
179
|
+
const projectNames = new Set<string>();
|
|
180
|
+
for (const n of Object.values(workflow.nodes)) {
|
|
181
|
+
if (n.project) projectNames.add(n.project);
|
|
182
|
+
}
|
|
183
|
+
const seenPaths = new Set<string>();
|
|
184
|
+
for (const pName of projectNames) {
|
|
185
|
+
// Lightweight template expansion — pipeline.ts resolveTemplate is
|
|
186
|
+
// not exported. Only handles `{{input.X}}` which covers the
|
|
187
|
+
// common case (`project: "{{input.project}}"`). Literal names
|
|
188
|
+
// pass through unchanged.
|
|
189
|
+
const resolved = pName.replace(/\{\{\s*input\.([\w-]+)\s*\}\}/g, (_, k: string) => input[k] ?? '');
|
|
190
|
+
if (!resolved) continue;
|
|
191
|
+
const pInfo = getProjectInfo(resolved);
|
|
192
|
+
if (!pInfo || seenPaths.has(pInfo.path)) continue;
|
|
193
|
+
seenPaths.add(pInfo.path);
|
|
194
|
+
for (const skill of skills) {
|
|
195
|
+
try {
|
|
196
|
+
const r = await ensureInstalledInProject(skill, pInfo.path);
|
|
197
|
+
if (!r.installed) console.warn(`[schedules] skill "${skill}" not installable in ${pInfo.path}: ${r.reason}`);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.warn(`[schedules] skill "${skill}" install failed in ${pInfo.path}: ${(e as Error).message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── skill body ───────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Skill body: create a one-shot Claude task in the configured project
|
|
209
|
+
* with the skill loaded via --append-system-prompt. We use the task as
|
|
210
|
+
* the dispatched unit; on completion the task event listener captures
|
|
211
|
+
* task.resultSummary and settles the schedule_run.
|
|
212
|
+
*
|
|
213
|
+
* Required input fields:
|
|
214
|
+
* - project (string, project name from Forge's local projects list)
|
|
215
|
+
*
|
|
216
|
+
* Optional input fields:
|
|
217
|
+
* - user_prompt (string, the actual question to ask Claude)
|
|
218
|
+
* defaults to "Run skill /<body_ref>"
|
|
219
|
+
* - <any other key/value> — passed in the user message verbatim
|
|
220
|
+
* (advanced; phase 2 doesn't do template expansion)
|
|
221
|
+
*/
|
|
222
|
+
async function executeSkillBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
|
|
223
|
+
const projectName = strField(s.input.project);
|
|
224
|
+
if (!projectName) {
|
|
225
|
+
throw new Error('skill body requires input.project (Forge project name)');
|
|
226
|
+
}
|
|
227
|
+
const project = getProjectInfo(projectName);
|
|
228
|
+
if (!project) {
|
|
229
|
+
throw new Error(`project not found: "${projectName}"`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pre-install body_ref + any extras into the target project. Idempotent;
|
|
233
|
+
// body_ref was validated as installed at POST time but only on SOME
|
|
234
|
+
// project (or globally) — make sure it's actually on disk for THIS one.
|
|
235
|
+
const skillsToInstall = [s.body_ref, ...(s.skills || [])].filter((v, i, arr) => v && arr.indexOf(v) === i);
|
|
236
|
+
for (const skill of skillsToInstall) {
|
|
237
|
+
try {
|
|
238
|
+
const r = await ensureInstalledInProject(skill, project.path);
|
|
239
|
+
if (!r.installed) console.warn(`[schedules] skill "${skill}" not installable in ${project.path}: ${r.reason}`);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
console.warn(`[schedules] skill "${skill}" install failed in ${project.path}: ${(e as Error).message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const userPrompt = strField(s.input.user_prompt) || `Run skill /${s.body_ref}`;
|
|
246
|
+
// Pass other input keys as a JSON block appended after user_prompt
|
|
247
|
+
// so the skill can extract them.
|
|
248
|
+
const extras: Record<string, string> = {};
|
|
249
|
+
for (const [k, v] of Object.entries(s.input || {})) {
|
|
250
|
+
if (k === 'project' || k === 'user_prompt') continue;
|
|
251
|
+
extras[k] = strField(v);
|
|
252
|
+
}
|
|
253
|
+
const extrasBlock = Object.keys(extras).length
|
|
254
|
+
? `\n\n--- Schedule input ---\n${JSON.stringify(extras, null, 2)}\n`
|
|
255
|
+
: '';
|
|
256
|
+
|
|
257
|
+
const task = createTask({
|
|
258
|
+
projectName: project.name,
|
|
259
|
+
projectPath: project.path,
|
|
260
|
+
prompt: userPrompt + extrasBlock,
|
|
261
|
+
conversationId: '',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Skill is hooked via --append-system-prompt before the runner picks
|
|
265
|
+
// the task up. Same mechanism Pipelines use (lib/pipeline.ts:1448+).
|
|
266
|
+
// body_ref is the primary skill; extra s.skills are merged for tasks
|
|
267
|
+
// that need additional skills alongside (e.g. /forge-mr-triage + /git-helpers).
|
|
268
|
+
const skillSet = [s.body_ref, ...(s.skills || [])].filter((v, i, arr) => v && arr.indexOf(v) === i);
|
|
269
|
+
const append = renderSkillsAppendPrompt(skillSet);
|
|
270
|
+
if (append) taskAppendSystemPromptOverrides.set(task.id, append);
|
|
271
|
+
|
|
272
|
+
insertScheduleRun({ schedule_id: s.id, target_id: task.id, trigger });
|
|
273
|
+
setLastRunAt(s.id, toSqlIso(new Date()));
|
|
274
|
+
return task.id;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── connector_tool body ──────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Connector tool body: a single synchronous HTTP-style invocation
|
|
281
|
+
* (run through tool-dispatcher; no Claude, no pipeline). body_ref is
|
|
282
|
+
* "<plugin>.<tool>" (e.g. "mantis.search_bugs"). input is passed
|
|
283
|
+
* verbatim as tool input.
|
|
284
|
+
*
|
|
285
|
+
* Synthetic target_id (uuid prefix "ct_") since there's no backing
|
|
286
|
+
* task/pipeline row. The schedule_run carries the full lifecycle.
|
|
287
|
+
*
|
|
288
|
+
* Dispatch is fire-and-forget — we return the target_id immediately
|
|
289
|
+
* so the caller (tick loop / manual fire API) doesn't block on slow
|
|
290
|
+
* connector tools. The async helper settles body_output + status
|
|
291
|
+
* when the call resolves.
|
|
292
|
+
*/
|
|
293
|
+
function executeConnectorToolBody(s: Schedule, trigger: ScheduleRunTrigger): string {
|
|
294
|
+
const targetId = `ct_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
|
|
295
|
+
insertScheduleRun({ schedule_id: s.id, target_id: targetId, trigger });
|
|
296
|
+
setLastRunAt(s.id, toSqlIso(new Date()));
|
|
297
|
+
void invokeConnectorTool(s, targetId).catch((err) => {
|
|
298
|
+
console.error(`[schedules] connector_tool body for ${s.id} crashed`, err);
|
|
299
|
+
updateScheduleRunStatus({
|
|
300
|
+
target_id: targetId,
|
|
301
|
+
status: 'failed',
|
|
302
|
+
error: (err as Error)?.message || String(err),
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
return targetId;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function invokeConnectorTool(s: Schedule, targetId: string): Promise<void> {
|
|
309
|
+
// body_ref is "<plugin_id>.<tool_name>". Some tools could contain
|
|
310
|
+
// dots, but our internal naming is always plugin.tool and the
|
|
311
|
+
// dispatcher splits on the FIRST dot.
|
|
312
|
+
const dot = s.body_ref.indexOf('.');
|
|
313
|
+
if (dot <= 0 || dot === s.body_ref.length - 1) {
|
|
314
|
+
updateScheduleRunStatus({
|
|
315
|
+
target_id: targetId,
|
|
316
|
+
status: 'failed',
|
|
317
|
+
error: `invalid body_ref: expected "<plugin>.<tool>", got "${s.body_ref}"`,
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const fullName = s.body_ref;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const result = await dispatchTool({
|
|
325
|
+
id: `schedule-${targetId}`,
|
|
326
|
+
name: fullName,
|
|
327
|
+
input: s.input || {},
|
|
328
|
+
});
|
|
329
|
+
const output = typeof result.content === 'string' ? result.content : JSON.stringify(result.content ?? '');
|
|
330
|
+
setScheduleRunBodyOutputByTarget(targetId, output);
|
|
331
|
+
if (result.is_error) {
|
|
332
|
+
updateScheduleRunStatus({
|
|
333
|
+
target_id: targetId,
|
|
334
|
+
status: 'failed',
|
|
335
|
+
error: output.slice(0, 500) || 'connector tool returned is_error',
|
|
336
|
+
});
|
|
337
|
+
} else {
|
|
338
|
+
updateScheduleRunStatus({ target_id: targetId, status: 'done' });
|
|
339
|
+
}
|
|
340
|
+
} catch (err) {
|
|
341
|
+
updateScheduleRunStatus({
|
|
342
|
+
target_id: targetId,
|
|
343
|
+
status: 'failed',
|
|
344
|
+
error: (err as Error)?.message || String(err),
|
|
345
|
+
});
|
|
346
|
+
} finally {
|
|
347
|
+
void runActionForTarget(targetId).catch((e) => {
|
|
348
|
+
console.error(`[schedules-scheduler] action for connector_tool target ${targetId} crashed`, e);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function strField(v: unknown): string {
|
|
354
|
+
if (v == null) return '';
|
|
355
|
+
return typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Duplicated from lib/pipeline.ts:renderSkillsAppendPrompt — small enough
|
|
359
|
+
* to inline rather than create an import edge. */
|
|
360
|
+
function renderSkillsAppendPrompt(skills: string[] | undefined): string {
|
|
361
|
+
if (!skills || skills.length === 0) return '';
|
|
362
|
+
const lines = skills.map((s) => ` /${s}`).join('\n');
|
|
363
|
+
return [
|
|
364
|
+
'For this task, the following Forge skills are available and should be used as appropriate:',
|
|
365
|
+
lines,
|
|
366
|
+
"Read each skill's SKILL.md before invoking. Prefer invoking these skills (with /<name>) over reimplementing their workflows inline.",
|
|
367
|
+
].join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Skill task settler ───────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Global task event listener that settles schedule_runs whose target_id
|
|
374
|
+
* is a Claude task (i.e. body_kind='skill'). We only act on terminal
|
|
375
|
+
* status events; reads getTask to capture resultSummary as body_output.
|
|
376
|
+
*
|
|
377
|
+
* Idempotent: updateScheduleRunStatus only touches rows with status='started',
|
|
378
|
+
* so a duplicate event is a no-op.
|
|
379
|
+
*/
|
|
380
|
+
let skillListenerInstalled = false;
|
|
381
|
+
function installSkillTaskListener(): void {
|
|
382
|
+
if (skillListenerInstalled) return;
|
|
383
|
+
skillListenerInstalled = true;
|
|
384
|
+
onTaskEvent((taskId, event, data) => {
|
|
385
|
+
if (event !== 'status') return;
|
|
386
|
+
if (data !== 'done' && data !== 'failed' && data !== 'cancelled') return;
|
|
387
|
+
settleSkillRunIfMatch(taskId, data as ScheduleRunStatus);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function settleSkillRunIfMatch(taskId: string, status: ScheduleRunStatus): void {
|
|
392
|
+
// We don't know body kind without a join; just attempt the update.
|
|
393
|
+
// updateScheduleRunStatus only touches rows with status='started'
|
|
394
|
+
// AND target_id=taskId, so it's a no-op for non-schedule tasks.
|
|
395
|
+
const t = getTask(taskId);
|
|
396
|
+
if (!t) {
|
|
397
|
+
updateScheduleRunStatus({ target_id: taskId, status, error: 'task row gone' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
setScheduleRunBodyOutputByTarget(taskId, t.resultSummary || null);
|
|
401
|
+
updateScheduleRunStatus({
|
|
402
|
+
target_id: taskId,
|
|
403
|
+
status,
|
|
404
|
+
error: status === 'failed' ? (t.error || null) : null,
|
|
405
|
+
});
|
|
406
|
+
void runActionForTarget(taskId).catch((e) => {
|
|
407
|
+
console.error(`[schedules-scheduler] action for skill target ${taskId} crashed`, e);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Refusing a manual fire when something's already inflight unless caller
|
|
413
|
+
* passed cancel_inflight=1. Returns null if not busy. Returns a reason
|
|
414
|
+
* string if busy.
|
|
415
|
+
*/
|
|
416
|
+
export function isScheduleBusy(id: string): { busy: boolean; reason: string } {
|
|
417
|
+
const s = getSchedule(id);
|
|
418
|
+
if (!s) return { busy: false, reason: '' };
|
|
419
|
+
const n = countInflightForSchedule(id);
|
|
420
|
+
if (n > 0) return { busy: true, reason: `${n} body run${n === 1 ? '' : 's'} still running from this schedule` };
|
|
421
|
+
return { busy: false, reason: '' };
|
|
422
|
+
}
|