@aion0/forge 0.8.5 → 0.8.7
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 +7 -4
- package/app/api/agents/route.ts +11 -1
- package/app/api/jobs/preview/route.ts +54 -5
- package/app/api/jobs/recipes/route.ts +59 -0
- package/app/api/workflows/marketplace/route.ts +52 -0
- package/bin/forge-server.mjs +21 -0
- package/components/PipelineView.tsx +255 -7
- package/components/SettingsModal.tsx +45 -10
- package/components/SkillsPanel.tsx +151 -17
- package/components/WorkspaceView.tsx +3 -1
- package/install.sh +28 -0
- package/lib/agents/index.ts +6 -1
- package/lib/chat/agent-loop.ts +37 -3
- package/lib/chat/llm/anthropic.ts +22 -4
- package/lib/chat/protocols/http.ts +46 -2
- package/lib/chat/tool-dispatcher.ts +21 -3
- package/lib/jobs/recipes.ts +260 -0
- package/lib/jobs/scheduler.ts +17 -2
- package/lib/pipeline.ts +5 -610
- package/lib/settings.ts +6 -0
- package/lib/workflow-marketplace.ts +275 -0
- package/package.json +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipes — yaml templates for Jobs.
|
|
3
|
+
*
|
|
4
|
+
* A recipe bundles "what tool to poll" + "how to dispatch" + "what shape
|
|
5
|
+
* of input_template to fill" into a single file. The user picks one,
|
|
6
|
+
* fills 2-4 form fields, and Forge instantiates a complete Job row.
|
|
7
|
+
*
|
|
8
|
+
* Storage: <dataDir>/recipes/<name>.yaml (one file per recipe).
|
|
9
|
+
* Sync (Stage 2B, separate): forge-workflow git repo pulls into the
|
|
10
|
+
* same dir as `source: 'registry'` rows.
|
|
11
|
+
*
|
|
12
|
+
* Schema (v1):
|
|
13
|
+
*
|
|
14
|
+
* name: gitlab-mr-watch
|
|
15
|
+
* display_name: "GitLab MR auto-fix"
|
|
16
|
+
* description: "..."
|
|
17
|
+
* version: "0.1.0"
|
|
18
|
+
* author: "forge"
|
|
19
|
+
* tags: [gitlab, mr]
|
|
20
|
+
*
|
|
21
|
+
* params: # form fields shown to the user
|
|
22
|
+
* - { name, label, type, required, default, options, help }
|
|
23
|
+
*
|
|
24
|
+
* job: # Job row template; values can use templates
|
|
25
|
+
* name_template, enabled, schedule_kind, schedule_interval_minutes,
|
|
26
|
+
* source_connector, source_tool, source_input, items_path,
|
|
27
|
+
* dedup_field, dispatch_type, dispatch_params { workflow_name,
|
|
28
|
+
* project_path, project_name, input_template }
|
|
29
|
+
*
|
|
30
|
+
* Templates: {{params.X}} / {{env.X}} / {{recipe.X}}. {{item.X}} is
|
|
31
|
+
* deliberately left LITERAL — at instantiation time we don't know the
|
|
32
|
+
* connector items yet; the scheduler later renders {{item.X}} per row.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import YAML from 'yaml';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
import { getDataDir } from '../dirs';
|
|
40
|
+
import { createJob } from './store';
|
|
41
|
+
import type { Job, PipelineDispatchParams, ChatDispatchParams } from './types';
|
|
42
|
+
|
|
43
|
+
export type ParamType = 'string' | 'number' | 'boolean' | 'select' | 'project_picker' | 'gitlab_mr_url';
|
|
44
|
+
|
|
45
|
+
export interface RecipeParam {
|
|
46
|
+
name: string;
|
|
47
|
+
label: string;
|
|
48
|
+
type?: ParamType;
|
|
49
|
+
required?: boolean;
|
|
50
|
+
default?: string | number | boolean;
|
|
51
|
+
options?: string[]; // for type=select
|
|
52
|
+
help?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Recipe {
|
|
56
|
+
name: string;
|
|
57
|
+
display_name: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
version: string;
|
|
60
|
+
author?: string;
|
|
61
|
+
tags?: string[];
|
|
62
|
+
params: RecipeParam[];
|
|
63
|
+
job: any; // free-form — instantiateRecipe walks it
|
|
64
|
+
/** Filesystem path the recipe was loaded from (set by listRecipes). */
|
|
65
|
+
_path?: string;
|
|
66
|
+
/** 'registry' (synced from forge-workflow) or 'local' (uploaded). */
|
|
67
|
+
source?: 'registry' | 'local';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const RECIPES_DIR = () => join(getDataDir(), 'recipes');
|
|
71
|
+
|
|
72
|
+
// ─── Load ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function recipesDir(): string {
|
|
75
|
+
const d = RECIPES_DIR();
|
|
76
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
77
|
+
return d;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function listRecipes(): Recipe[] {
|
|
81
|
+
const dir = recipesDir();
|
|
82
|
+
const out: Recipe[] = [];
|
|
83
|
+
for (const name of readdirSync(dir)) {
|
|
84
|
+
if (!name.endsWith('.yaml') && !name.endsWith('.yml')) continue;
|
|
85
|
+
const path = join(dir, name);
|
|
86
|
+
try {
|
|
87
|
+
const r = YAML.parse(readFileSync(path, 'utf-8')) as Recipe;
|
|
88
|
+
if (!r || !r.name) continue;
|
|
89
|
+
r._path = path;
|
|
90
|
+
// Default source=local for hand-uploaded files; sync-layer (2B)
|
|
91
|
+
// will rewrite this to 'registry' as part of its install step.
|
|
92
|
+
r.source = (r as any).source === 'registry' ? 'registry' : 'local';
|
|
93
|
+
out.push(r);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn(`[recipes] failed to parse ${path}:`, (e as Error).message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out.sort((a, b) => a.display_name.localeCompare(b.display_name));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getRecipe(name: string): Recipe | null {
|
|
102
|
+
return listRecipes().find((r) => r.name === name) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Save / Delete (local upload path) ─────────────────────
|
|
106
|
+
|
|
107
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
108
|
+
|
|
109
|
+
export function saveRecipeYaml(yamlText: string): { ok: true; name: string } | { ok: false; error: string } {
|
|
110
|
+
let parsed: any;
|
|
111
|
+
try { parsed = YAML.parse(yamlText); }
|
|
112
|
+
catch (e) { return { ok: false, error: `invalid YAML: ${(e as Error).message}` }; }
|
|
113
|
+
const name = String(parsed?.name || '').trim();
|
|
114
|
+
if (!name) return { ok: false, error: 'recipe.name is required' };
|
|
115
|
+
if (!NAME_RE.test(name)) return { ok: false, error: `recipe.name "${name}" must be lowercase alphanumerics + hyphens/underscores` };
|
|
116
|
+
if (!Array.isArray(parsed.params)) return { ok: false, error: 'recipe.params (array) is required' };
|
|
117
|
+
if (!parsed.job || typeof parsed.job !== 'object') return { ok: false, error: 'recipe.job (object) is required' };
|
|
118
|
+
const path = join(recipesDir(), `${name}.yaml`);
|
|
119
|
+
writeFileSync(path, yamlText, { mode: 0o600 });
|
|
120
|
+
return { ok: true, name };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function deleteRecipe(name: string): boolean {
|
|
124
|
+
if (!NAME_RE.test(name)) return false;
|
|
125
|
+
const path = join(recipesDir(), `${name}.yaml`);
|
|
126
|
+
const altPath = join(recipesDir(), `${name}.yml`);
|
|
127
|
+
const { unlinkSync } = require('node:fs');
|
|
128
|
+
if (existsSync(path)) { unlinkSync(path); return true; }
|
|
129
|
+
if (existsSync(altPath)) { unlinkSync(altPath); return true; }
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Template rendering ────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
interface RenderContext {
|
|
136
|
+
params: Record<string, any>;
|
|
137
|
+
env: Record<string, string>;
|
|
138
|
+
recipe: { name: string; display_name: string; version: string };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const KNOWN_NAMESPACES = new Set(['params', 'env', 'recipe']);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Expand `{{X.path}}` tokens in a string. Only `params.`, `env.`, and
|
|
145
|
+
* `recipe.` are resolved; anything else (notably `{{item.X}}`) is left
|
|
146
|
+
* verbatim so the scheduler can render per-item at runtime.
|
|
147
|
+
*/
|
|
148
|
+
function renderString(input: string, ctx: RenderContext): string {
|
|
149
|
+
if (!input) return input;
|
|
150
|
+
return input.replace(/\{\{\s*([a-zA-Z0-9_]+)\.([a-zA-Z0-9_.]+)\s*\}\}/g, (full, ns, path) => {
|
|
151
|
+
if (!KNOWN_NAMESPACES.has(ns)) return full;
|
|
152
|
+
const root: any = (ctx as any)[ns];
|
|
153
|
+
let cur: any = root;
|
|
154
|
+
for (const seg of path.split('.')) {
|
|
155
|
+
if (cur == null || typeof cur !== 'object') { cur = undefined; break; }
|
|
156
|
+
cur = cur[seg];
|
|
157
|
+
}
|
|
158
|
+
if (cur == null) return '';
|
|
159
|
+
if (typeof cur === 'string') return cur;
|
|
160
|
+
if (typeof cur === 'number' || typeof cur === 'boolean') return String(cur);
|
|
161
|
+
try { return JSON.stringify(cur); } catch { return full; }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderDeep(value: any, ctx: RenderContext): any {
|
|
166
|
+
if (value == null) return value;
|
|
167
|
+
if (typeof value === 'string') return renderString(value, ctx);
|
|
168
|
+
if (Array.isArray(value)) return value.map((v) => renderDeep(v, ctx));
|
|
169
|
+
if (typeof value === 'object') {
|
|
170
|
+
const out: Record<string, any> = {};
|
|
171
|
+
for (const [k, v] of Object.entries(value)) out[k] = renderDeep(v, ctx);
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Instantiate ───────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export interface InstantiateResult {
|
|
180
|
+
ok: boolean;
|
|
181
|
+
job?: Job;
|
|
182
|
+
error?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Render a recipe + user params into a complete Job row and persist it.
|
|
187
|
+
* Returns the created Job (with assigned id). Validates required params
|
|
188
|
+
* and coerces numeric/boolean fields based on the param type.
|
|
189
|
+
*/
|
|
190
|
+
export function instantiateRecipe(name: string, rawParams: Record<string, any>): InstantiateResult {
|
|
191
|
+
const recipe = getRecipe(name);
|
|
192
|
+
if (!recipe) return { ok: false, error: `recipe "${name}" not found` };
|
|
193
|
+
|
|
194
|
+
// Coerce + validate params per the recipe's param schema
|
|
195
|
+
const params: Record<string, any> = {};
|
|
196
|
+
for (const p of recipe.params) {
|
|
197
|
+
const incoming = rawParams[p.name];
|
|
198
|
+
let val: any = incoming != null && incoming !== '' ? incoming : (p.default ?? '');
|
|
199
|
+
if (p.required && (val === '' || val == null)) {
|
|
200
|
+
return { ok: false, error: `param "${p.name}" is required` };
|
|
201
|
+
}
|
|
202
|
+
if (p.type === 'number' && val !== '') {
|
|
203
|
+
const n = Number(val);
|
|
204
|
+
if (!Number.isFinite(n)) return { ok: false, error: `param "${p.name}" must be a number` };
|
|
205
|
+
val = n;
|
|
206
|
+
} else if (p.type === 'boolean') {
|
|
207
|
+
val = !!val && val !== 'false';
|
|
208
|
+
} else if (p.type === 'gitlab_mr_url' && val !== '') {
|
|
209
|
+
// Parse a full GitLab MR URL into two derived params:
|
|
210
|
+
// <name>__path → "namespace/repo" (URL-encoded path-style id)
|
|
211
|
+
// <name>__iid → numeric MR iid
|
|
212
|
+
// Lets a recipe ask the user for just ONE field (the MR URL) and
|
|
213
|
+
// still produce both `project_id` and `mr_iid` for the underlying
|
|
214
|
+
// tool calls.
|
|
215
|
+
const m = String(val).match(/^https?:\/\/[^/]+\/(.+?)\/-\/merge_requests\/(\d+)/);
|
|
216
|
+
if (!m) {
|
|
217
|
+
return { ok: false, error: `param "${p.name}" must be a GitLab MR URL like https://host/namespace/repo/-/merge_requests/N — got: ${String(val).slice(0, 80)}` };
|
|
218
|
+
}
|
|
219
|
+
params[`${p.name}__path`] = m[1];
|
|
220
|
+
params[`${p.name}__iid`] = parseInt(m[2], 10);
|
|
221
|
+
}
|
|
222
|
+
params[p.name] = val;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const ctx: RenderContext = {
|
|
226
|
+
params,
|
|
227
|
+
env: { HOME: process.env.HOME || homedir(), USER: process.env.USER || '' },
|
|
228
|
+
recipe: { name: recipe.name, display_name: recipe.display_name, version: recipe.version },
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const rendered = renderDeep(recipe.job, ctx) as any;
|
|
232
|
+
|
|
233
|
+
// Pull out the (rendered) job name; fall back to "<recipe>-<timestamp>".
|
|
234
|
+
const jobName = String(rendered.name_template || rendered.name || `${recipe.name}-${Date.now()}`).trim();
|
|
235
|
+
// Remove keys the createJob() signature doesn't accept directly.
|
|
236
|
+
delete rendered.name_template;
|
|
237
|
+
delete rendered.name;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const job = createJob({
|
|
241
|
+
name: jobName,
|
|
242
|
+
enabled: rendered.enabled !== false,
|
|
243
|
+
schedule_kind: rendered.schedule_kind || 'period',
|
|
244
|
+
schedule_interval_minutes: Number(rendered.schedule_interval_minutes) || 30,
|
|
245
|
+
schedule_at: rendered.schedule_at ?? null,
|
|
246
|
+
schedule_cron: rendered.schedule_cron ?? null,
|
|
247
|
+
source_connector: String(rendered.source_connector || ''),
|
|
248
|
+
source_tool: String(rendered.source_tool || ''),
|
|
249
|
+
source_input: rendered.source_input || {},
|
|
250
|
+
items_path: String(rendered.items_path || ''),
|
|
251
|
+
dedup_field: String(rendered.dedup_field || 'id'),
|
|
252
|
+
dispatch_type: rendered.dispatch_type || 'pipeline',
|
|
253
|
+
dispatch_params: (rendered.dispatch_params || {}) as PipelineDispatchParams | ChatDispatchParams,
|
|
254
|
+
skills: Array.isArray(rendered.skills) ? rendered.skills : [],
|
|
255
|
+
});
|
|
256
|
+
return { ok: true, job };
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return { ok: false, error: `failed to create job: ${(e as Error).message}` };
|
|
259
|
+
}
|
|
260
|
+
}
|
package/lib/jobs/scheduler.ts
CHANGED
|
@@ -168,7 +168,13 @@ export async function executeRun(job: Job, runId: string): Promise<void> {
|
|
|
168
168
|
logLine('info', `source input: ${JSON.stringify(sourceInput)}`);
|
|
169
169
|
logLine('info', `calling connector ${callName}…`);
|
|
170
170
|
|
|
171
|
-
const toolResult = await dispatchTool(
|
|
171
|
+
const toolResult = await dispatchTool(
|
|
172
|
+
{ id: `job-${runId}`, name: callName, input: sourceInput },
|
|
173
|
+
// We JSON.parse the response — the 8KB LLM-friendly truncation
|
|
174
|
+
// would break parsing on any moderately large list (Todos, big MR
|
|
175
|
+
// searches, etc.). Ask for the raw body.
|
|
176
|
+
{ noTruncation: true },
|
|
177
|
+
);
|
|
172
178
|
|
|
173
179
|
const respBytes = toolResult.content?.length ?? 0;
|
|
174
180
|
logLine(toolResult.is_error ? 'error' : 'info',
|
|
@@ -366,7 +372,16 @@ export async function executeRun(job: Job, runId: string): Promise<void> {
|
|
|
366
372
|
|
|
367
373
|
function safeParseJson(content: string): unknown | undefined {
|
|
368
374
|
try { return JSON.parse(content); }
|
|
369
|
-
catch {
|
|
375
|
+
catch {}
|
|
376
|
+
// Strip the http-protocol preamble — `HTTP 200 OK · GET <url>\n[(...)]\n\n`
|
|
377
|
+
// (and the truncation note line that sometimes follows) — and retry.
|
|
378
|
+
// The LLM-facing payload bakes in a status line we don't want here.
|
|
379
|
+
const firstJson = content.search(/[\[{]/);
|
|
380
|
+
if (firstJson > 0) {
|
|
381
|
+
try { return JSON.parse(content.slice(firstJson)); }
|
|
382
|
+
catch {}
|
|
383
|
+
}
|
|
384
|
+
return undefined;
|
|
370
385
|
}
|
|
371
386
|
|
|
372
387
|
function pickPath(obj: unknown, path: string): unknown {
|