@aion0/forge 0.9.12 → 0.9.13
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 +12 -6
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScheduleQuickCreate — V3 D entry point (#266).
|
|
5
|
+
*
|
|
6
|
+
* Two stages:
|
|
7
|
+
* 1. Natural-language textarea → POST /api/schedules/extract
|
|
8
|
+
* 2. ScheduleConfirmCard renders the parsed spec, lets user adjust
|
|
9
|
+
* fields inline, then POSTs to /api/prompts + /api/schedules.
|
|
10
|
+
*
|
|
11
|
+
* Always half-automatic — Forge never auto-creates the schedule. The
|
|
12
|
+
* user must Confirm. Low confidence (< 0.7) or non-empty `missing[]`
|
|
13
|
+
* shows a banner asking them to verify before saving.
|
|
14
|
+
*
|
|
15
|
+
* Chat-thread integration (extractor invoked mid-chat with a Confirm Card
|
|
16
|
+
* inline) is a follow-up — same Card component will be reused there.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
interface Extracted {
|
|
22
|
+
name: string;
|
|
23
|
+
prompt_name: string;
|
|
24
|
+
prompt: string;
|
|
25
|
+
agent: string | null;
|
|
26
|
+
skills: string[];
|
|
27
|
+
project: string | null;
|
|
28
|
+
trigger: {
|
|
29
|
+
kind: 'period' | 'cron' | 'once' | 'manual';
|
|
30
|
+
interval_minutes: number | null;
|
|
31
|
+
cron: string | null;
|
|
32
|
+
at_iso: string | null;
|
|
33
|
+
};
|
|
34
|
+
action: {
|
|
35
|
+
kind: 'none' | 'chat' | 'email' | 'telegram';
|
|
36
|
+
config: Record<string, unknown>;
|
|
37
|
+
};
|
|
38
|
+
confidence: number;
|
|
39
|
+
missing: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AgentInfo { id: string; label?: string; backendType?: string }
|
|
43
|
+
interface ProjectInfo { name: string; path: string }
|
|
44
|
+
|
|
45
|
+
interface Props {
|
|
46
|
+
onClose: () => void;
|
|
47
|
+
onCreated: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function ScheduleQuickCreate({ onClose, onCreated }: Props) {
|
|
51
|
+
const [text, setText] = useState('');
|
|
52
|
+
const [extracting, setExtracting] = useState(false);
|
|
53
|
+
const [extracted, setExtracted] = useState<Extracted | null>(null);
|
|
54
|
+
const [err, setErr] = useState('');
|
|
55
|
+
|
|
56
|
+
async function extract() {
|
|
57
|
+
setErr('');
|
|
58
|
+
setExtracting(true);
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch('/api/schedules/extract', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ text: text.trim() }),
|
|
64
|
+
});
|
|
65
|
+
const j = await r.json();
|
|
66
|
+
if (!r.ok || !j.ok) {
|
|
67
|
+
setErr(j.error || `HTTP ${r.status}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setExtracted(j.extracted as Extracted);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
73
|
+
} finally {
|
|
74
|
+
setExtracting(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[10vh]">
|
|
80
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[680px] max-w-[95vw] max-h-[85vh] flex flex-col">
|
|
81
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center">
|
|
82
|
+
<h2 className="text-[13px] font-semibold flex-1">✨ Quick create schedule</h2>
|
|
83
|
+
<button onClick={onClose} className="text-[14px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-6 h-6">✕</button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="flex-1 overflow-auto p-4">
|
|
87
|
+
{!extracted && (
|
|
88
|
+
<>
|
|
89
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-2">
|
|
90
|
+
Describe what you want Forge to run on a schedule. Be specific about
|
|
91
|
+
<em> when</em>, <em>what</em>, and <em>where to send the result</em>.
|
|
92
|
+
Forge will parse it into a draft you can review and tweak before saving.
|
|
93
|
+
</div>
|
|
94
|
+
<textarea
|
|
95
|
+
value={text}
|
|
96
|
+
onChange={(e) => setText(e.target.value)}
|
|
97
|
+
rows={6}
|
|
98
|
+
placeholder="e.g. 每天上午 9 点查一下我今天分到的 mantis bug,发我 Telegram"
|
|
99
|
+
className="w-full text-[12px] font-mono px-3 py-2 border border-[var(--border)] rounded bg-[var(--bg-secondary)] resize-y"
|
|
100
|
+
/>
|
|
101
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
102
|
+
Your wording stays verbatim — Forge does NOT rewrite it.
|
|
103
|
+
</div>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{extracted && (
|
|
108
|
+
<ScheduleConfirmCard
|
|
109
|
+
initial={extracted}
|
|
110
|
+
originalText={text}
|
|
111
|
+
onCancel={() => setExtracted(null)}
|
|
112
|
+
onCreated={onCreated}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mt-3">{err}</div>}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{!extracted && (
|
|
120
|
+
<div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between">
|
|
121
|
+
<button onClick={onClose} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
|
|
122
|
+
<button
|
|
123
|
+
onClick={() => void extract()}
|
|
124
|
+
disabled={!text.trim() || extracting}
|
|
125
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
126
|
+
>{extracting ? 'Parsing…' : 'Parse →'}</button>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ScheduleConfirmCard({
|
|
135
|
+
initial, originalText, onCancel, onCreated,
|
|
136
|
+
}: {
|
|
137
|
+
initial: Extracted;
|
|
138
|
+
originalText: string;
|
|
139
|
+
onCancel: () => void;
|
|
140
|
+
onCreated: () => void;
|
|
141
|
+
}) {
|
|
142
|
+
const [name, setName] = useState(initial.name || initial.prompt_name || 'untitled');
|
|
143
|
+
const [promptText, setPromptText] = useState(initial.prompt);
|
|
144
|
+
const [agent, setAgent] = useState(initial.agent || '');
|
|
145
|
+
const [skills, setSkills] = useState<string[]>(initial.skills);
|
|
146
|
+
const [skillsRaw, setSkillsRaw] = useState(initial.skills.join(', '));
|
|
147
|
+
const [project, setProject] = useState(initial.project || '');
|
|
148
|
+
const [triggerKind, setTriggerKind] = useState(initial.trigger.kind);
|
|
149
|
+
const [interval, setInterval] = useState(initial.trigger.interval_minutes ?? 30);
|
|
150
|
+
const [cron, setCron] = useState(initial.trigger.cron ?? '');
|
|
151
|
+
const [atIso, setAtIso] = useState(initial.trigger.at_iso ?? '');
|
|
152
|
+
const [actionKind, setActionKind] = useState(initial.action.kind);
|
|
153
|
+
const [actionConfigJson, setActionConfigJson] = useState(
|
|
154
|
+
JSON.stringify(initial.action.config ?? {}, null, 2)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
158
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
159
|
+
const [submitting, setSubmitting] = useState(false);
|
|
160
|
+
const [err, setErr] = useState('');
|
|
161
|
+
|
|
162
|
+
const lowConfidence = initial.confidence < 0.7 || initial.missing.length > 0;
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
(async () => {
|
|
166
|
+
try {
|
|
167
|
+
const [skR, agR] = await Promise.all([
|
|
168
|
+
fetch('/api/skills').then((r) => r.json()),
|
|
169
|
+
fetch('/api/agents?include=all').then((r) => r.json()).catch(() => ({ agents: [] })),
|
|
170
|
+
]);
|
|
171
|
+
if (Array.isArray(skR?.projects)) setProjects(skR.projects);
|
|
172
|
+
if (Array.isArray(agR?.agents)) setAgents(agR.agents);
|
|
173
|
+
} catch {/* ignore — fields fall back to free text */}
|
|
174
|
+
})();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
async function submit() {
|
|
178
|
+
setErr('');
|
|
179
|
+
setSubmitting(true);
|
|
180
|
+
try {
|
|
181
|
+
const cleanedSkills = skillsRaw
|
|
182
|
+
.split(/[,\s]+/)
|
|
183
|
+
.map((s) => s.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
// 1. Create prompts/<name>.yaml. Auto-suffix on 409 (same logic as
|
|
186
|
+
// ScheduleCreateModal — keeps quick-create idempotent across retries).
|
|
187
|
+
const base = slugifyName(name) || initial.prompt_name || `prompt-${Date.now().toString(36).slice(-6)}`;
|
|
188
|
+
let promptNameToUse: string | null = null;
|
|
189
|
+
let candidate = base;
|
|
190
|
+
for (let i = 2; i <= 50; i++) {
|
|
191
|
+
const r = await fetch('/api/prompts', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'content-type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
name: candidate,
|
|
196
|
+
prompt: promptText,
|
|
197
|
+
executor: { agent: agent || null, skills: cleanedSkills },
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
if (r.ok) { promptNameToUse = candidate; break; }
|
|
201
|
+
if (r.status === 409) { candidate = `${base}-${i}`; continue; }
|
|
202
|
+
const j = await r.json().catch(() => ({}));
|
|
203
|
+
setErr(j.error || `prompt save failed (${r.status})`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!promptNameToUse) {
|
|
207
|
+
setErr(`couldn't find a free prompt name starting from "${base}"`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 2. Create the schedule
|
|
212
|
+
let actionConfig: Record<string, unknown>;
|
|
213
|
+
try {
|
|
214
|
+
actionConfig = actionConfigJson.trim() ? JSON.parse(actionConfigJson) : {};
|
|
215
|
+
} catch (e) {
|
|
216
|
+
setErr(`action_config is not valid JSON: ${(e as Error).message}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const body: Record<string, unknown> = {
|
|
220
|
+
name,
|
|
221
|
+
body_kind: 'prompt',
|
|
222
|
+
body_ref: promptNameToUse,
|
|
223
|
+
input: { project },
|
|
224
|
+
enabled: true,
|
|
225
|
+
schedule_kind: triggerKind,
|
|
226
|
+
schedule_interval_minutes: triggerKind === 'period' ? interval : null,
|
|
227
|
+
schedule_cron: triggerKind === 'cron' ? cron.trim() : null,
|
|
228
|
+
schedule_at: triggerKind === 'once' && atIso ? new Date(atIso).toISOString() : null,
|
|
229
|
+
action_kind: actionKind,
|
|
230
|
+
action_config: actionConfig,
|
|
231
|
+
};
|
|
232
|
+
const r = await fetch('/api/schedules', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'content-type': 'application/json' },
|
|
235
|
+
body: JSON.stringify(body),
|
|
236
|
+
});
|
|
237
|
+
if (!r.ok) {
|
|
238
|
+
const j = await r.json().catch(() => ({}));
|
|
239
|
+
setErr(j.error || `schedule create failed (${r.status})`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
onCreated();
|
|
243
|
+
} catch (e) {
|
|
244
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
245
|
+
} finally {
|
|
246
|
+
setSubmitting(false);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="space-y-3">
|
|
252
|
+
<div className="text-[11px] text-[var(--text-secondary)]">
|
|
253
|
+
Forge parsed your request below. Review, tweak anything, then Create.
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{lowConfidence && (
|
|
257
|
+
<div className="text-[11px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 border border-[var(--yellow)]/30">
|
|
258
|
+
⚠ confidence {initial.confidence.toFixed(2)}
|
|
259
|
+
{initial.missing.length > 0 && <> · missing: <code className="font-mono">{initial.missing.join(', ')}</code></>}
|
|
260
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">Verify the fields before saving.</div>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
<Field label="Schedule name">
|
|
265
|
+
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
|
|
266
|
+
</Field>
|
|
267
|
+
|
|
268
|
+
<Field label="Prompt (verbatim)" hint="The agent will receive this exact text. Tweak if Forge mis-quoted.">
|
|
269
|
+
<textarea
|
|
270
|
+
value={promptText}
|
|
271
|
+
onChange={(e) => setPromptText(e.target.value)}
|
|
272
|
+
rows={5}
|
|
273
|
+
className={inputCls + ' font-mono'}
|
|
274
|
+
/>
|
|
275
|
+
</Field>
|
|
276
|
+
|
|
277
|
+
<div className="grid grid-cols-2 gap-3">
|
|
278
|
+
<Field label="Project" hint="Leave on scratch for prompts that just call connectors.">
|
|
279
|
+
<select value={project} onChange={(e) => setProject(e.target.value)} className={inputCls}>
|
|
280
|
+
<option value="">— scratch (default) —</option>
|
|
281
|
+
{projects.filter((p) => p.name !== 'scratch').map((p) => <option key={p.name} value={p.name}>{p.name}</option>)}
|
|
282
|
+
{project && project !== 'scratch' && !projects.some((p) => p.name === project) && (
|
|
283
|
+
<option value={project}>{project} (not in projects list)</option>
|
|
284
|
+
)}
|
|
285
|
+
</select>
|
|
286
|
+
</Field>
|
|
287
|
+
<Field label="Agent">
|
|
288
|
+
<select value={agent} onChange={(e) => setAgent(e.target.value)} className={inputCls}>
|
|
289
|
+
<option value="">— system default —</option>
|
|
290
|
+
{agents.map((a) => <option key={a.id} value={a.id}>{a.label || a.id}</option>)}
|
|
291
|
+
</select>
|
|
292
|
+
</Field>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<Field label="Skills" hint="Comma-separated. ai-orchestration is the default V3 skill (lets the agent call connectors).">
|
|
296
|
+
<input value={skillsRaw} onChange={(e) => setSkillsRaw(e.target.value)} className={inputCls + ' font-mono'} />
|
|
297
|
+
</Field>
|
|
298
|
+
|
|
299
|
+
<Field label="Trigger">
|
|
300
|
+
<div className="flex gap-2 items-baseline">
|
|
301
|
+
<select
|
|
302
|
+
value={triggerKind}
|
|
303
|
+
onChange={(e) => setTriggerKind(e.target.value as Extracted['trigger']['kind'])}
|
|
304
|
+
className={inputCls + ' w-32'}
|
|
305
|
+
>
|
|
306
|
+
<option value="period">period</option>
|
|
307
|
+
<option value="cron">cron</option>
|
|
308
|
+
<option value="once">once</option>
|
|
309
|
+
<option value="manual">manual</option>
|
|
310
|
+
</select>
|
|
311
|
+
{triggerKind === 'period' && (
|
|
312
|
+
<>
|
|
313
|
+
<input
|
|
314
|
+
type="number" min={1}
|
|
315
|
+
value={interval}
|
|
316
|
+
onChange={(e) => setInterval(Number(e.target.value) || 1)}
|
|
317
|
+
className={inputCls + ' w-24'}
|
|
318
|
+
/>
|
|
319
|
+
<span className="text-[10px] text-[var(--text-secondary)]">minutes</span>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
{triggerKind === 'cron' && (
|
|
323
|
+
<input value={cron} onChange={(e) => setCron(e.target.value)} placeholder="e.g. 0 9 * * *"
|
|
324
|
+
className={inputCls + ' font-mono flex-1'} />
|
|
325
|
+
)}
|
|
326
|
+
{triggerKind === 'once' && (
|
|
327
|
+
<input
|
|
328
|
+
type="datetime-local"
|
|
329
|
+
value={atIso ? new Date(atIso).toISOString().slice(0, 16) : ''}
|
|
330
|
+
onChange={(e) => setAtIso(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
|
331
|
+
className={inputCls + ' flex-1'}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</Field>
|
|
336
|
+
|
|
337
|
+
<Field label="Action">
|
|
338
|
+
<div className="flex gap-2 items-start">
|
|
339
|
+
<select
|
|
340
|
+
value={actionKind}
|
|
341
|
+
onChange={(e) => setActionKind(e.target.value as Extracted['action']['kind'])}
|
|
342
|
+
className={inputCls + ' w-32'}
|
|
343
|
+
>
|
|
344
|
+
<option value="none">none</option>
|
|
345
|
+
<option value="chat">chat</option>
|
|
346
|
+
<option value="email">email</option>
|
|
347
|
+
<option value="telegram">telegram</option>
|
|
348
|
+
</select>
|
|
349
|
+
{actionKind !== 'none' && (
|
|
350
|
+
<textarea
|
|
351
|
+
value={actionConfigJson}
|
|
352
|
+
onChange={(e) => setActionConfigJson(e.target.value)}
|
|
353
|
+
rows={3}
|
|
354
|
+
placeholder='{ "chat_id": "..." }'
|
|
355
|
+
className={inputCls + ' font-mono flex-1'}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
{actionKind !== 'none' && (
|
|
360
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
361
|
+
JSON object; see Schedules help doc for the per-action shape. Chat needs <code className="font-mono">session_id</code>, email needs <code className="font-mono">to</code>, telegram needs <code className="font-mono">chat_id</code> (or default subscriber).
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</Field>
|
|
365
|
+
|
|
366
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2">{err}</div>}
|
|
367
|
+
|
|
368
|
+
<details className="text-[10px] text-[var(--text-secondary)]">
|
|
369
|
+
<summary className="cursor-pointer">Original text</summary>
|
|
370
|
+
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{originalText}</pre>
|
|
371
|
+
</details>
|
|
372
|
+
|
|
373
|
+
<div className="flex justify-between pt-2 border-t border-[var(--border)]">
|
|
374
|
+
<button onClick={onCancel} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">← Re-parse</button>
|
|
375
|
+
<button
|
|
376
|
+
onClick={() => void submit()}
|
|
377
|
+
disabled={submitting || !promptText.trim() || !name.trim() || (triggerKind === 'cron' && !cron.trim()) || (triggerKind === 'once' && !atIso)}
|
|
378
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
379
|
+
>{submitting ? 'Creating…' : 'Confirm create'}</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const inputCls = 'text-[11px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] w-full';
|
|
386
|
+
|
|
387
|
+
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
388
|
+
return (
|
|
389
|
+
<div>
|
|
390
|
+
<div className="text-[11px] mb-0.5">{label}</div>
|
|
391
|
+
{children}
|
|
392
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Mirror the slug rule in ScheduleCreateModal so quick-create + manual create
|
|
398
|
+
// produce comparable prompt filenames.
|
|
399
|
+
function slugifyName(s: string): string {
|
|
400
|
+
return s.toLowerCase()
|
|
401
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
402
|
+
.replace(/^-+|-+$/g, '')
|
|
403
|
+
.slice(0, 50);
|
|
404
|
+
}
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import { useCallback, useEffect, useState } from 'react';
|
|
16
16
|
import ScheduleCreateModal from './ScheduleCreateModal';
|
|
17
|
+
import ScheduleQuickCreate from './ScheduleQuickCreate';
|
|
17
18
|
|
|
18
19
|
interface Schedule {
|
|
19
20
|
id: string;
|
|
20
21
|
name: string;
|
|
21
22
|
enabled: boolean;
|
|
22
|
-
body_kind: 'pipeline' | '
|
|
23
|
+
body_kind: 'pipeline' | 'prompt';
|
|
23
24
|
body_ref: string;
|
|
24
25
|
input: Record<string, unknown>;
|
|
25
26
|
skills: string[];
|
|
@@ -66,6 +67,7 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
66
67
|
const [runsBySchedule, setRunsBySchedule] = useState<Record<string, ScheduleRun[]>>({});
|
|
67
68
|
const [filter, setFilter] = useState<Filter>('all');
|
|
68
69
|
const [showCreate, setShowCreate] = useState(false);
|
|
70
|
+
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
|
69
71
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
|
70
72
|
|
|
71
73
|
const refresh = useCallback(async () => {
|
|
@@ -190,6 +192,11 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
190
192
|
</p>
|
|
191
193
|
</div>
|
|
192
194
|
<div className="flex items-center gap-2">
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => setShowQuickCreate(true)}
|
|
197
|
+
className="text-[10px] px-3 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)]/10"
|
|
198
|
+
title="Describe in natural language; Forge parses + you confirm"
|
|
199
|
+
>✨ Quick create</button>
|
|
193
200
|
<button
|
|
194
201
|
onClick={() => setShowCreate(true)}
|
|
195
202
|
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold hover:opacity-90"
|
|
@@ -252,6 +259,12 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
252
259
|
onCreated={() => { setShowCreate(false); void refresh(); }}
|
|
253
260
|
/>
|
|
254
261
|
)}
|
|
262
|
+
{showQuickCreate && (
|
|
263
|
+
<ScheduleQuickCreate
|
|
264
|
+
onClose={() => setShowQuickCreate(false)}
|
|
265
|
+
onCreated={() => { setShowQuickCreate(false); void refresh(); }}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
255
268
|
{editingSchedule && (
|
|
256
269
|
<ScheduleCreateModal
|
|
257
270
|
existing={editingSchedule}
|
|
@@ -503,10 +516,9 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
|
503
516
|
isPipelineBody: boolean;
|
|
504
517
|
onViewPipeline?: (pipelineId: string) => void;
|
|
505
518
|
}) {
|
|
506
|
-
// body_output/error captured for
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
// to expanded so the user doesn't have to hunt.
|
|
519
|
+
// body_output/error captured for prompt runs aren't navigable to a
|
|
520
|
+
// pipeline detail page; the only place to surface them is right here,
|
|
521
|
+
// inline. Failed runs default to expanded so the user doesn't have to hunt.
|
|
510
522
|
// Surface ALL three stages — trigger / body / action — instead of
|
|
511
523
|
// just body status. Skipped or failed action was previously
|
|
512
524
|
// invisible (e.g. "body status was started" race where action got
|
|
@@ -514,7 +526,7 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
|
514
526
|
// to see WHY chat/email/telegram never fired).
|
|
515
527
|
const actionFailedOrSkipped = r.action_status === 'failed' || r.action_status === 'skipped';
|
|
516
528
|
const [expanded, setExpanded] = useState(r.status === 'failed' || actionFailedOrSkipped);
|
|
517
|
-
const canOpenPipeline = isPipelineBody && !!onViewPipeline
|
|
529
|
+
const canOpenPipeline = isPipelineBody && !!onViewPipeline;
|
|
518
530
|
const hasDetails = !!(r.body_output || r.error || r.action_error);
|
|
519
531
|
|
|
520
532
|
return (
|
package/lib/forge-mcp-server.ts
CHANGED
|
@@ -394,6 +394,90 @@ function createForgeMcpServer(sessionId: string): McpServer {
|
|
|
394
394
|
}
|
|
395
395
|
);
|
|
396
396
|
|
|
397
|
+
// ── list_connectors ────────────────────────────
|
|
398
|
+
// V3 ai-orchestration MVP — let an agent discover what installed
|
|
399
|
+
// connectors + tools it can call. Pairs with call_connector below.
|
|
400
|
+
server.tool(
|
|
401
|
+
'list_connectors',
|
|
402
|
+
'List installed Forge connectors and their callable tools. Use this BEFORE call_connector to discover what tool ids exist and what parameters each expects. Returns id, tool name, description, parameter schema, destructive flag, and (when present) author-provided returns hint.',
|
|
403
|
+
{},
|
|
404
|
+
async () => {
|
|
405
|
+
try {
|
|
406
|
+
const { listInstalledConnectors } = await import('./connectors/registry');
|
|
407
|
+
const connectors = listInstalledConnectors().filter((c) => c.enabled);
|
|
408
|
+
if (connectors.length === 0) {
|
|
409
|
+
return {
|
|
410
|
+
content: [{ type: 'text', text: 'No connectors installed. Install one from Settings → Marketplace.' }],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
type ToolSpec = { description?: string; parameters?: Record<string, unknown>; destructive?: boolean; returns?: string; protocol?: string };
|
|
414
|
+
const out: Array<{
|
|
415
|
+
plugin_id: string;
|
|
416
|
+
name: string;
|
|
417
|
+
version: string;
|
|
418
|
+
tools: Array<{ name: string; description: string; parameters?: Record<string, unknown>; destructive?: boolean; returns?: string; protocol?: string }>;
|
|
419
|
+
}> = [];
|
|
420
|
+
for (const c of connectors) {
|
|
421
|
+
const def = c.definition as { id: string; name: string; version: string; tools?: Record<string, ToolSpec>; connectors?: Array<{ id: string; tools: Record<string, ToolSpec> }> };
|
|
422
|
+
// Flatten: single-entry connectors expose tools at top level, suite
|
|
423
|
+
// connectors (Atlassian-style) under .connectors[].tools.
|
|
424
|
+
const sources: Array<[string, Record<string, ToolSpec>]> = [];
|
|
425
|
+
if (def.tools) sources.push([def.id, def.tools]);
|
|
426
|
+
for (const entry of def.connectors || []) sources.push([entry.id, entry.tools]);
|
|
427
|
+
for (const [pluginId, tools] of sources) {
|
|
428
|
+
out.push({
|
|
429
|
+
plugin_id: pluginId,
|
|
430
|
+
name: def.name,
|
|
431
|
+
version: def.version,
|
|
432
|
+
tools: Object.entries(tools).map(([toolName, t]) => ({
|
|
433
|
+
name: toolName,
|
|
434
|
+
description: t.description || '(no description)',
|
|
435
|
+
...(t.parameters ? { parameters: t.parameters } : {}),
|
|
436
|
+
...(t.destructive ? { destructive: true } : {}),
|
|
437
|
+
...(t.returns ? { returns: t.returns } : {}),
|
|
438
|
+
...(t.protocol ? { protocol: t.protocol } : {}),
|
|
439
|
+
})),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
444
|
+
} catch (err: any) {
|
|
445
|
+
return { content: [{ type: 'text', text: `Error listing connectors: ${err.message}` }] };
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// ── call_connector ─────────────────────────────
|
|
451
|
+
// Invoke one connector tool. Same backend as POST /api/connector-tool
|
|
452
|
+
// (single internal entry point — anything pipelines / jobs / chat already
|
|
453
|
+
// call is what an ai-orchestration-skilled agent gets too).
|
|
454
|
+
server.tool(
|
|
455
|
+
'call_connector',
|
|
456
|
+
'Call one installed connector tool. Use list_connectors first to find plugin_id + tool_name. Returns the tool result text; sets is_error=true on failure. Destructive tools (add_comment, send_message, etc.) require explicit user confirmation in the surrounding workflow — the call itself is not blocked here.',
|
|
457
|
+
{
|
|
458
|
+
plugin_id: z.string().describe("Connector id from list_connectors (e.g. 'mantis', 'gitlab', 'teams')."),
|
|
459
|
+
tool: z.string().describe("Tool name within the connector (e.g. 'get_bug', 'search_mrs', 'send_message')."),
|
|
460
|
+
input: z.record(z.string(), z.any()).optional().describe('Parameter object matching the tool schema returned by list_connectors. Omit for parameter-less tools.'),
|
|
461
|
+
},
|
|
462
|
+
async (params) => {
|
|
463
|
+
try {
|
|
464
|
+
const { dispatchTool } = await import('./chat/tool-dispatcher');
|
|
465
|
+
const name = `${params.plugin_id}.${params.tool}`;
|
|
466
|
+
const result = await dispatchTool({
|
|
467
|
+
id: `mcp-${Date.now()}`,
|
|
468
|
+
name,
|
|
469
|
+
input: params.input ?? {},
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: 'text', text: result.content }],
|
|
473
|
+
...(result.is_error ? { isError: true } : {}),
|
|
474
|
+
};
|
|
475
|
+
} catch (err: any) {
|
|
476
|
+
return { content: [{ type: 'text', text: `Error calling ${params.plugin_id}.${params.tool}: ${err.message}` }], isError: true };
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
397
481
|
// ── get_pipeline_status ────────────────────────
|
|
398
482
|
server.tool(
|
|
399
483
|
'get_pipeline_status',
|
package/lib/init.ts
CHANGED
|
@@ -86,6 +86,13 @@ export function ensureInitialized() {
|
|
|
86
86
|
|
|
87
87
|
time('logger', () => { try { const { initLogger } = require('./logger'); initLogger(); } catch {} });
|
|
88
88
|
time('migrateDataDir', () => { try { const { migrateDataDir } = require('./dirs'); migrateDataDir(); } catch {} });
|
|
89
|
+
time('ensureScratchProject', () => {
|
|
90
|
+
// Synthetic 'scratch' project under <dataDir>/scratch — default
|
|
91
|
+
// workspace for prompt schedules / chat-launched temp tasks that
|
|
92
|
+
// don't care about a real project.
|
|
93
|
+
try { const { ensureScratchProject } = require('./projects'); ensureScratchProject(); }
|
|
94
|
+
catch (e) { console.warn('[init] ensureScratchProject failed:', (e as Error).message); }
|
|
95
|
+
});
|
|
89
96
|
time('migrateSecrets', migrateSecrets);
|
|
90
97
|
time('migratePluginSecrets', () => {
|
|
91
98
|
try {
|
package/lib/projects.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadSettings } from './settings';
|
|
4
|
+
import { getDataDir } from './dirs';
|
|
4
5
|
|
|
5
6
|
export interface LocalProject {
|
|
6
7
|
name: string;
|
|
@@ -12,6 +13,67 @@ export interface LocalProject {
|
|
|
12
13
|
lastModified: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Reserved name for the synthetic "scratch" project that lives under
|
|
17
|
+
* <dataDir>/scratch. Default workspace for tasks that don't need a real
|
|
18
|
+
* project (prompt schedules calling connectors / chat-launched temp tasks
|
|
19
|
+
* / anything that just talks to APIs and doesn't touch files). */
|
|
20
|
+
export const SCRATCH_PROJECT_NAME = 'scratch';
|
|
21
|
+
|
|
22
|
+
/** Materialize <dataDir>/scratch on first call. Idempotent. Returns the
|
|
23
|
+
* project path. Called from init.ts on startup so the dir exists before
|
|
24
|
+
* any task tries to cd into it. */
|
|
25
|
+
export function ensureScratchProject(): string {
|
|
26
|
+
const dir = join(getDataDir(), 'scratch');
|
|
27
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
28
|
+
const claudeMd = join(dir, 'CLAUDE.md');
|
|
29
|
+
if (!existsSync(claudeMd)) {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(claudeMd,
|
|
32
|
+
'# Forge Scratch\n\n' +
|
|
33
|
+
'Default workspace for tasks that do not target a specific project.\n' +
|
|
34
|
+
'Files here are managed by Forge (per-instance, follows the data dir).\n' +
|
|
35
|
+
'Safe to wipe by hand if it accumulates clutter.\n',
|
|
36
|
+
'utf-8',
|
|
37
|
+
);
|
|
38
|
+
} catch { /* best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
// Scratch is Forge-owned (no user hand-rolled .mcp.json to defy), so
|
|
41
|
+
// unlike real projects we materialize the root .mcp.json directly here.
|
|
42
|
+
// Without it, prompt-mode schedule tasks running in scratch can't see
|
|
43
|
+
// the Forge MCP server (claude CLI only auto-discovers root .mcp.json,
|
|
44
|
+
// not .forge/mcp.json). Always rewrite to keep mcpPort + user-managed
|
|
45
|
+
// entries in sync.
|
|
46
|
+
try {
|
|
47
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
48
|
+
let userServers: Record<string, unknown> = {};
|
|
49
|
+
try { userServers = loadSettings().mcpServers || {}; } catch {}
|
|
50
|
+
const cfg = {
|
|
51
|
+
mcpServers: {
|
|
52
|
+
forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse` },
|
|
53
|
+
...userServers,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
writeFileSync(join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2), 'utf-8');
|
|
57
|
+
} catch { /* best-effort */ }
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scratchProject(): LocalProject {
|
|
62
|
+
const path = ensureScratchProject();
|
|
63
|
+
let mtime: string;
|
|
64
|
+
try { mtime = statSync(path).mtime.toISOString(); }
|
|
65
|
+
catch { mtime = new Date().toISOString(); }
|
|
66
|
+
return {
|
|
67
|
+
name: SCRATCH_PROJECT_NAME,
|
|
68
|
+
path,
|
|
69
|
+
root: getDataDir(),
|
|
70
|
+
hasGit: false,
|
|
71
|
+
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
|
72
|
+
language: null,
|
|
73
|
+
lastModified: mtime,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
15
77
|
export function scanProjects(): LocalProject[] {
|
|
16
78
|
const settings = loadSettings();
|
|
17
79
|
const roots = settings.projectRoots;
|
|
@@ -52,7 +114,10 @@ export function scanProjects(): LocalProject[] {
|
|
|
52
114
|
}
|
|
53
115
|
}
|
|
54
116
|
|
|
55
|
-
|
|
117
|
+
// Prepend the synthetic scratch project so the UI surfaces it as a
|
|
118
|
+
// first-class option. It's stamped with the current date so it tends
|
|
119
|
+
// to sort to the top — that's intentional: it's the default fallback.
|
|
120
|
+
return [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
|
|
56
121
|
}
|
|
57
122
|
|
|
58
123
|
function detectLanguage(projectPath: string): string | null {
|