@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,275 @@
|
|
|
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
|
+
// Marketplace = registry catalog ONLY. Recipes / pipelines that
|
|
134
|
+
// exist only locally (uploaded copy, hand-edited file, custom
|
|
135
|
+
// workflow) belong to the user and don't show here — Marketplace
|
|
136
|
+
// is for browsing the remote forge-workflow repo. We still mark
|
|
137
|
+
// registry items as `installed` when a local copy exists, so the
|
|
138
|
+
// UI can show "your local copy is on v0.2.0, registry is v0.3.0".
|
|
139
|
+
for (const ir of installedRecipes) {
|
|
140
|
+
const cached = recipeMap.get(ir.name);
|
|
141
|
+
if (cached) {
|
|
142
|
+
cached.installed = true;
|
|
143
|
+
cached.installed_version = ir.version;
|
|
144
|
+
cached.has_update = compareVersions(cached.version, ir.version) > 0;
|
|
145
|
+
}
|
|
146
|
+
// else: local-only recipe — intentionally skipped
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const pipelineMap = new Map<string, MarketplaceEntry>();
|
|
150
|
+
for (const p of (cache.pipelines || [])) {
|
|
151
|
+
if (!p.name) continue;
|
|
152
|
+
pipelineMap.set(p.name, {
|
|
153
|
+
kind: 'pipeline',
|
|
154
|
+
name: p.name,
|
|
155
|
+
display_name: p.display_name || p.name,
|
|
156
|
+
description: p.description,
|
|
157
|
+
version: p.version || '0.0.0',
|
|
158
|
+
author: p.author,
|
|
159
|
+
tags: p.tags,
|
|
160
|
+
score: p.score ?? 0,
|
|
161
|
+
rating: p.rating ?? 0,
|
|
162
|
+
source: 'registry',
|
|
163
|
+
installed: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// Same model for pipelines: Marketplace lists only registry items.
|
|
167
|
+
// Local-only workflows (yours, edited, custom) live in the
|
|
168
|
+
// Pipelines tab list — they're shown there, not here. This split
|
|
169
|
+
// keeps "browse upstream catalog" and "manage your local files"
|
|
170
|
+
// as two distinct mental modes.
|
|
171
|
+
for (const iw of installedWorkflows) {
|
|
172
|
+
if (iw.builtin) continue;
|
|
173
|
+
const cached = pipelineMap.get(iw.name);
|
|
174
|
+
if (cached) {
|
|
175
|
+
cached.installed = true;
|
|
176
|
+
cached.installed_version = (iw as any).version || cached.version;
|
|
177
|
+
}
|
|
178
|
+
// else: local-only pipeline — intentionally skipped
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
recipes: Array.from(recipeMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
|
183
|
+
pipelines: Array.from(pipelineMap.values()).sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Install ───────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async function fetchText(url: string): Promise<string | null> {
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
if (!res.ok) return null;
|
|
196
|
+
return await res.text();
|
|
197
|
+
} catch { clearTimeout(timer); return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function installFromMarketplace(
|
|
201
|
+
kind: WorkflowKind,
|
|
202
|
+
name: string,
|
|
203
|
+
opts: { target_name?: string; overwrite?: boolean } = {},
|
|
204
|
+
): Promise<{ ok: boolean; error?: string; installed_as?: string }> {
|
|
205
|
+
const base = baseUrl();
|
|
206
|
+
if (!base) return { ok: false, error: 'workflowRepoUrl is empty' };
|
|
207
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
|
|
208
|
+
|
|
209
|
+
const targetName = opts.target_name || name;
|
|
210
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(targetName)) {
|
|
211
|
+
return { ok: false, error: `target_name "${targetName}" must be lowercase alphanumerics + hyphens/underscores` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Recipes live under recipes/<name>/recipe.yaml, pipelines under
|
|
215
|
+
// pipelines/<name>/pipeline.yaml. Falls back to a flat file too,
|
|
216
|
+
// for repos that don't use a per-item folder.
|
|
217
|
+
const subdir = kind === 'recipe' ? 'recipes' : 'pipelines';
|
|
218
|
+
const file = kind === 'recipe' ? 'recipe.yaml' : 'pipeline.yaml';
|
|
219
|
+
const candidates = [
|
|
220
|
+
`${base}/${subdir}/${name}/${file}`,
|
|
221
|
+
`${base}/${subdir}/${name}.yaml`,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
let yamlText: string | null = null;
|
|
225
|
+
for (const url of candidates) {
|
|
226
|
+
yamlText = await fetchText(url);
|
|
227
|
+
if (yamlText) break;
|
|
228
|
+
}
|
|
229
|
+
if (!yamlText) return { ok: false, error: `${kind} "${name}" not found in registry` };
|
|
230
|
+
|
|
231
|
+
// Validate it parses + has the right shape before writing.
|
|
232
|
+
let parsed: any;
|
|
233
|
+
try {
|
|
234
|
+
parsed = YAML.parse(yamlText);
|
|
235
|
+
if (!parsed?.name) return { ok: false, error: 'yaml missing top-level "name"' };
|
|
236
|
+
if (parsed.name !== name) return { ok: false, error: `yaml name "${parsed.name}" doesn't match registry id "${name}"` };
|
|
237
|
+
if (kind === 'recipe') {
|
|
238
|
+
if (!Array.isArray(parsed.params)) return { ok: false, error: 'recipe yaml missing "params" array' };
|
|
239
|
+
if (!parsed.job) return { ok: false, error: 'recipe yaml missing "job" block' };
|
|
240
|
+
}
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return { ok: false, error: `invalid YAML: ${(e as Error).message}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const targetDir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
|
|
246
|
+
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
247
|
+
const targetPath = join(targetDir, `${targetName}.yaml`);
|
|
248
|
+
if (existsSync(targetPath) && !opts.overwrite) {
|
|
249
|
+
return { ok: false, error: `"${targetName}" already exists locally — pass overwrite=true to replace it, or choose a different name` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// If renaming, rewrite the yaml's top-level `name:` so the on-disk
|
|
253
|
+
// copy matches its filename. Without this the file would be loaded
|
|
254
|
+
// as the original name and collide with other clones.
|
|
255
|
+
let outputYaml = yamlText;
|
|
256
|
+
if (targetName !== name) {
|
|
257
|
+
parsed.name = targetName;
|
|
258
|
+
outputYaml = YAML.stringify(parsed);
|
|
259
|
+
}
|
|
260
|
+
writeFileSync(targetPath, outputYaml, { mode: 0o600 });
|
|
261
|
+
return { ok: true, installed_as: targetName };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Uninstall ─────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export function uninstallFromMarketplace(kind: WorkflowKind, name: string): { ok: boolean; error?: string } {
|
|
267
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return { ok: false, error: 'invalid name' };
|
|
268
|
+
const dir = kind === 'recipe' ? join(getDataDir(), 'recipes') : join(getDataDir(), 'flows');
|
|
269
|
+
const yamlPath = join(dir, `${name}.yaml`);
|
|
270
|
+
const ymlPath = join(dir, `${name}.yml`);
|
|
271
|
+
const { unlinkSync } = require('node:fs');
|
|
272
|
+
if (existsSync(yamlPath)) { unlinkSync(yamlPath); return { ok: true }; }
|
|
273
|
+
if (existsSync(ymlPath)) { unlinkSync(ymlPath); return { ok: true }; }
|
|
274
|
+
return { ok: false, error: 'not installed' };
|
|
275
|
+
}
|