@aion0/forge 0.8.5 → 0.8.6
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 +4 -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 +247 -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 +287 -0
- package/package.json +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Marketplace — sync recipes + pipelines from a Git repo.
|
|
3
|
+
*
|
|
4
|
+
* Repo layout (default: aiwatching/forge-workflow):
|
|
5
|
+
*
|
|
6
|
+
* registry.json
|
|
7
|
+
* { version, updated_at, recipes: [...summary], pipelines: [...summary] }
|
|
8
|
+
* recipes/<name>/recipe.yaml ← the actual recipe content
|
|
9
|
+
* recipes/<name>/info.json ← optional richer metadata (rating, tags)
|
|
10
|
+
* pipelines/<name>/pipeline.yaml
|
|
11
|
+
* pipelines/<name>/info.json
|
|
12
|
+
*
|
|
13
|
+
* Local cache: <dataDir>/workflow-cache.json — last fetched registry.
|
|
14
|
+
* Installed status is derived by checking the files on disk under
|
|
15
|
+
* <dataDir>/recipes/ (handled by lib/jobs/recipes.ts)
|
|
16
|
+
* <dataDir>/flows/ (handled by lib/pipeline.ts)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import YAML from 'yaml';
|
|
22
|
+
import { getDataDir } from './dirs';
|
|
23
|
+
import { loadSettings } from './settings';
|
|
24
|
+
import { listRecipes } from './jobs/recipes';
|
|
25
|
+
import { listWorkflows } from './pipeline';
|
|
26
|
+
|
|
27
|
+
export type WorkflowKind = 'recipe' | 'pipeline';
|
|
28
|
+
|
|
29
|
+
export interface MarketplaceEntry {
|
|
30
|
+
kind: WorkflowKind;
|
|
31
|
+
name: string;
|
|
32
|
+
display_name: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
version: string;
|
|
35
|
+
author?: string;
|
|
36
|
+
tags?: string[];
|
|
37
|
+
score?: number;
|
|
38
|
+
rating?: number;
|
|
39
|
+
source: 'registry' | 'local';
|
|
40
|
+
installed: boolean;
|
|
41
|
+
installed_version?: string;
|
|
42
|
+
has_update?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CACHE_FILE = () => join(getDataDir(), 'workflow-cache.json');
|
|
46
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
47
|
+
|
|
48
|
+
function baseUrl(): string {
|
|
49
|
+
return (loadSettings().workflowRepoUrl || '').replace(/\/+$/, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RegistryFile {
|
|
53
|
+
version: number;
|
|
54
|
+
updated_at?: string;
|
|
55
|
+
recipes?: Array<Partial<MarketplaceEntry>>;
|
|
56
|
+
pipelines?: Array<Partial<MarketplaceEntry>>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readCache(): RegistryFile | null {
|
|
60
|
+
try {
|
|
61
|
+
const path = CACHE_FILE();
|
|
62
|
+
if (!existsSync(path)) return null;
|
|
63
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeCache(data: RegistryFile): void {
|
|
68
|
+
mkdirSync(getDataDir(), { recursive: true });
|
|
69
|
+
writeFileSync(CACHE_FILE(), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Sync ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export async function syncMarketplace(): Promise<{ ok: boolean; recipes: number; pipelines: number; error?: string }> {
|
|
75
|
+
const base = baseUrl();
|
|
76
|
+
if (!base) return { ok: false, recipes: 0, pipelines: 0, error: 'workflowRepoUrl is empty' };
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
79
|
+
try {
|
|
80
|
+
const cacheBust = `_t=${Date.now()}`;
|
|
81
|
+
const res = await fetch(`${base}/registry.json?${cacheBust}`, {
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' },
|
|
84
|
+
});
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
if (!res.ok) return { ok: false, recipes: 0, pipelines: 0, error: `registry fetch failed: HTTP ${res.status}` };
|
|
87
|
+
const data = (await res.json()) as RegistryFile;
|
|
88
|
+
writeCache(data);
|
|
89
|
+
console.log(`[workflow-marketplace] synced ${data.recipes?.length ?? 0} recipes + ${data.pipelines?.length ?? 0} pipelines`);
|
|
90
|
+
return { ok: true, recipes: data.recipes?.length ?? 0, pipelines: data.pipelines?.length ?? 0 };
|
|
91
|
+
} catch (e) {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
const msg = (e as Error).message || String(e);
|
|
94
|
+
console.warn(`[workflow-marketplace] sync failed:`, msg);
|
|
95
|
+
return { ok: false, recipes: 0, pipelines: 0, error: msg };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── List (merged registry + installed) ────────────────────
|
|
100
|
+
|
|
101
|
+
function compareVersions(a: string, b: string): number {
|
|
102
|
+
const pa = (a || '0.0.0').split('.').map(Number);
|
|
103
|
+
const pb = (b || '0.0.0').split('.').map(Number);
|
|
104
|
+
for (let i = 0; i < 3; i++) {
|
|
105
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
106
|
+
if (diff !== 0) return diff;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function listMarketplace(): { recipes: MarketplaceEntry[]; pipelines: MarketplaceEntry[] } {
|
|
112
|
+
const cache: RegistryFile = readCache() || { version: 1, recipes: [], pipelines: [] };
|
|
113
|
+
const installedRecipes = listRecipes();
|
|
114
|
+
const installedWorkflows = listWorkflows();
|
|
115
|
+
|
|
116
|
+
const recipeMap = new Map<string, MarketplaceEntry>();
|
|
117
|
+
for (const r of (cache.recipes || [])) {
|
|
118
|
+
if (!r.name) continue;
|
|
119
|
+
recipeMap.set(r.name, {
|
|
120
|
+
kind: 'recipe',
|
|
121
|
+
name: r.name,
|
|
122
|
+
display_name: r.display_name || r.name,
|
|
123
|
+
description: r.description,
|
|
124
|
+
version: r.version || '0.0.0',
|
|
125
|
+
author: r.author,
|
|
126
|
+
tags: r.tags,
|
|
127
|
+
score: r.score ?? 0,
|
|
128
|
+
rating: r.rating ?? 0,
|
|
129
|
+
source: 'registry',
|
|
130
|
+
installed: false,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
for (const ir of installedRecipes) {
|
|
134
|
+
const cached = recipeMap.get(ir.name);
|
|
135
|
+
if (cached) {
|
|
136
|
+
cached.installed = true;
|
|
137
|
+
cached.installed_version = ir.version;
|
|
138
|
+
cached.has_update = compareVersions(cached.version, ir.version) > 0;
|
|
139
|
+
} else {
|
|
140
|
+
// Local-only recipe (uploaded, not in marketplace)
|
|
141
|
+
recipeMap.set(ir.name, {
|
|
142
|
+
kind: 'recipe',
|
|
143
|
+
name: ir.name,
|
|
144
|
+
display_name: ir.display_name,
|
|
145
|
+
description: ir.description,
|
|
146
|
+
version: ir.version,
|
|
147
|
+
author: ir.author,
|
|
148
|
+
tags: ir.tags,
|
|
149
|
+
source: 'local',
|
|
150
|
+
installed: true,
|
|
151
|
+
installed_version: ir.version,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const pipelineMap = new Map<string, MarketplaceEntry>();
|
|
157
|
+
for (const p of (cache.pipelines || [])) {
|
|
158
|
+
if (!p.name) continue;
|
|
159
|
+
pipelineMap.set(p.name, {
|
|
160
|
+
kind: 'pipeline',
|
|
161
|
+
name: p.name,
|
|
162
|
+
display_name: p.display_name || p.name,
|
|
163
|
+
description: p.description,
|
|
164
|
+
version: p.version || '0.0.0',
|
|
165
|
+
author: p.author,
|
|
166
|
+
tags: p.tags,
|
|
167
|
+
score: p.score ?? 0,
|
|
168
|
+
rating: p.rating ?? 0,
|
|
169
|
+
source: 'registry',
|
|
170
|
+
installed: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const iw of installedWorkflows) {
|
|
174
|
+
if (iw.builtin) continue; // built-ins aren't user workflows
|
|
175
|
+
const cached = pipelineMap.get(iw.name);
|
|
176
|
+
if (cached) {
|
|
177
|
+
cached.installed = true;
|
|
178
|
+
// pipeline yaml doesn't carry an explicit version; treat unknown.
|
|
179
|
+
cached.installed_version = (iw as any).version || cached.version;
|
|
180
|
+
} else {
|
|
181
|
+
pipelineMap.set(iw.name, {
|
|
182
|
+
kind: 'pipeline',
|
|
183
|
+
name: iw.name,
|
|
184
|
+
display_name: iw.name,
|
|
185
|
+
description: iw.description,
|
|
186
|
+
version: '0.0.0',
|
|
187
|
+
source: 'local',
|
|
188
|
+
installed: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
recipes: Array.from(recipeMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
|
195
|
+
pipelines: Array.from(pipelineMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Install ───────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
async function fetchText(url: string): Promise<string | null> {
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
204
|
+
try {
|
|
205
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
if (!res.ok) return null;
|
|
208
|
+
return await res.text();
|
|
209
|
+
} catch { clearTimeout(timer); return null; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function installFromMarketplace(
|
|
213
|
+
kind: WorkflowKind,
|
|
214
|
+
name: string,
|
|
215
|
+
opts: { target_name?: string; overwrite?: boolean } = {},
|
|
216
|
+
): Promise<{ ok: boolean; error?: string; installed_as?: string }> {
|
|
217
|
+
const base = baseUrl();
|
|
218
|
+
if (!base) return { ok: false, error: 'workflowRepoUrl is empty' };
|
|
219
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
|
|
220
|
+
|
|
221
|
+
const targetName = opts.target_name || name;
|
|
222
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(targetName)) {
|
|
223
|
+
return { ok: false, error: `target_name "${targetName}" must be lowercase alphanumerics + hyphens/underscores` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Recipes live under recipes/<name>/recipe.yaml, pipelines under
|
|
227
|
+
// pipelines/<name>/pipeline.yaml. Falls back to a flat file too,
|
|
228
|
+
// for repos that don't use a per-item folder.
|
|
229
|
+
const subdir = kind === 'recipe' ? 'recipes' : 'pipelines';
|
|
230
|
+
const file = kind === 'recipe' ? 'recipe.yaml' : 'pipeline.yaml';
|
|
231
|
+
const candidates = [
|
|
232
|
+
`${base}/${subdir}/${name}/${file}`,
|
|
233
|
+
`${base}/${subdir}/${name}.yaml`,
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
let yamlText: string | null = null;
|
|
237
|
+
for (const url of candidates) {
|
|
238
|
+
yamlText = await fetchText(url);
|
|
239
|
+
if (yamlText) break;
|
|
240
|
+
}
|
|
241
|
+
if (!yamlText) return { ok: false, error: `${kind} "${name}" not found in registry` };
|
|
242
|
+
|
|
243
|
+
// Validate it parses + has the right shape before writing.
|
|
244
|
+
let parsed: any;
|
|
245
|
+
try {
|
|
246
|
+
parsed = YAML.parse(yamlText);
|
|
247
|
+
if (!parsed?.name) return { ok: false, error: 'yaml missing top-level "name"' };
|
|
248
|
+
if (parsed.name !== name) return { ok: false, error: `yaml name "${parsed.name}" doesn't match registry id "${name}"` };
|
|
249
|
+
if (kind === 'recipe') {
|
|
250
|
+
if (!Array.isArray(parsed.params)) return { ok: false, error: 'recipe yaml missing "params" array' };
|
|
251
|
+
if (!parsed.job) return { ok: false, error: 'recipe yaml missing "job" block' };
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return { ok: false, error: `invalid YAML: ${(e as Error).message}` };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const targetDir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
|
|
258
|
+
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
259
|
+
const targetPath = join(targetDir, `${targetName}.yaml`);
|
|
260
|
+
if (existsSync(targetPath) && !opts.overwrite) {
|
|
261
|
+
return { ok: false, error: `"${targetName}" already exists locally — pass overwrite=true to replace it, or choose a different name` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// If renaming, rewrite the yaml's top-level `name:` so the on-disk
|
|
265
|
+
// copy matches its filename. Without this the file would be loaded
|
|
266
|
+
// as the original name and collide with other clones.
|
|
267
|
+
let outputYaml = yamlText;
|
|
268
|
+
if (targetName !== name) {
|
|
269
|
+
parsed.name = targetName;
|
|
270
|
+
outputYaml = YAML.stringify(parsed);
|
|
271
|
+
}
|
|
272
|
+
writeFileSync(targetPath, outputYaml, { mode: 0o600 });
|
|
273
|
+
return { ok: true, installed_as: targetName };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Uninstall ─────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
export function uninstallFromMarketplace(kind: WorkflowKind, name: string): { ok: boolean; error?: string } {
|
|
279
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
|
|
280
|
+
const dir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
|
|
281
|
+
const yamlPath = join(dir, `${name}.yaml`);
|
|
282
|
+
const ymlPath = join(dir, `${name}.yml`);
|
|
283
|
+
const { unlinkSync } = require('node:fs');
|
|
284
|
+
if (existsSync(yamlPath)) { unlinkSync(yamlPath); return { ok: true }; }
|
|
285
|
+
if (existsSync(ymlPath)) { unlinkSync(ymlPath); return { ok: true }; }
|
|
286
|
+
return { ok: false, error: 'not installed' };
|
|
287
|
+
}
|