@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.
@@ -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
+ }
@@ -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({ id: `job-${runId}`, name: callName, input: sourceInput });
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 { return undefined; }
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 {