@aion0/forge 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/RELEASE_NOTES.md +60 -5
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +106 -0
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. 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
+ }