@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
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Forge v0.8.
|
|
1
|
+
# Forge v0.8.6
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-21
|
|
4
4
|
|
|
5
|
-
## Changes since v0.8.
|
|
5
|
+
## Changes since v0.8.5
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.5...v0.8.6
|
package/app/api/agents/route.ts
CHANGED
|
@@ -11,7 +11,17 @@ export async function GET(req: Request) {
|
|
|
11
11
|
return NextResponse.json(info);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// ?include=cli|api|all (default: cli) — API profiles aren't useful in
|
|
15
|
+
// Terminal / Task / Pipeline pickers (no CLI binary to spawn), so the
|
|
16
|
+
// default hides them. WorkspaceView's API mode passes ?include=all
|
|
17
|
+
// (or ?include=api) to surface them when needed.
|
|
18
|
+
const include = (url.searchParams.get('include') || 'cli').toLowerCase();
|
|
19
|
+
const all = listAgents();
|
|
20
|
+
const agents = include === 'all'
|
|
21
|
+
? all
|
|
22
|
+
: include === 'api'
|
|
23
|
+
? all.filter((a: any) => a.backendType === 'api')
|
|
24
|
+
: all.filter((a: any) => a.backendType !== 'api');
|
|
15
25
|
const defaultAgent = getDefaultAgentId();
|
|
16
26
|
return NextResponse.json({ agents, defaultAgent });
|
|
17
27
|
}
|
|
@@ -46,6 +46,48 @@ function pickPath(obj: unknown, path: string): unknown {
|
|
|
46
46
|
return cur;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Guess the right items_path purely from the response shape, so the UI
|
|
51
|
+
* can offer it even when the user left the field blank (or set it wrong).
|
|
52
|
+
* - top-level array → '' (scheduler iterates directly)
|
|
53
|
+
* - object with exactly one array-valued key → that key
|
|
54
|
+
* - object with several array-valued keys → prefer common names
|
|
55
|
+
* - top-level single object → '' (scheduler wraps as 1-item)
|
|
56
|
+
*/
|
|
57
|
+
function suggestItemsPath(parsed: unknown): string | null {
|
|
58
|
+
if (Array.isArray(parsed)) return '';
|
|
59
|
+
if (parsed && typeof parsed === 'object') {
|
|
60
|
+
const obj = parsed as Record<string, unknown>;
|
|
61
|
+
const arrayKeys = Object.entries(obj).filter(([, v]) => Array.isArray(v)).map(([k]) => k);
|
|
62
|
+
if (arrayKeys.length === 0) return '';
|
|
63
|
+
// Only suggest a non-empty items_path when one of the keys looks
|
|
64
|
+
// like a *list payload* name. Otherwise this is probably a detail
|
|
65
|
+
// object (e.g. mantis.get_bug returns {id, summary, ..., history,
|
|
66
|
+
// notes}) — `history` is an internal array, not the items list.
|
|
67
|
+
// For detail responses, empty items_path → scheduler wraps the
|
|
68
|
+
// single object as a 1-item list.
|
|
69
|
+
const common = ['items', 'data', 'results', 'records', 'bugs', 'issues', 'merge_requests', 'mrs', 'projects', 'pipelines'];
|
|
70
|
+
for (const name of common) if (arrayKeys.includes(name)) return name;
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pick the most-likely unique-identifier key from the sample item.
|
|
78
|
+
* Order matches what users typically expect, with `iid` (GitLab project-scoped
|
|
79
|
+
* issue/MR id) preferred over the global `id` when both exist.
|
|
80
|
+
*/
|
|
81
|
+
function suggestDedupField(itemKeys: string[]): string | null {
|
|
82
|
+
// Prefer the globally-unique `id` over GitLab's project-scoped `iid`
|
|
83
|
+
// so dedup remains safe if a job ever spans multiple projects.
|
|
84
|
+
const priority = ['id', 'iid', 'key', 'uuid', 'global_id', 'guid', 'sha', 'commit_id'];
|
|
85
|
+
for (const p of priority) if (itemKeys.includes(p)) return p;
|
|
86
|
+
const trailingId = itemKeys.find((k) => /_id$/i.test(k));
|
|
87
|
+
if (trailingId) return trailingId;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
49
91
|
/**
|
|
50
92
|
* Best-effort match a workflow input name to a key on the sample item.
|
|
51
93
|
* The mantis case (bug_id ← id, base_branch ← nothing) drove this list:
|
|
@@ -109,11 +151,10 @@ export async function POST(req: Request) {
|
|
|
109
151
|
const callName = `${source_connector}.${source_tool}`;
|
|
110
152
|
let toolResult;
|
|
111
153
|
try {
|
|
112
|
-
toolResult = await dispatchTool(
|
|
113
|
-
id: `jobs-preview-${Date.now()}`,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
});
|
|
154
|
+
toolResult = await dispatchTool(
|
|
155
|
+
{ id: `jobs-preview-${Date.now()}`, name: callName, input: source_input || {} },
|
|
156
|
+
{ noTruncation: true },
|
|
157
|
+
);
|
|
117
158
|
} catch (e) {
|
|
118
159
|
return NextResponse.json({ ok: false, error: `connector call threw: ${(e as Error).message}` }, { status: 500 });
|
|
119
160
|
}
|
|
@@ -136,6 +177,11 @@ export async function POST(req: Request) {
|
|
|
136
177
|
}, { status: 200 });
|
|
137
178
|
}
|
|
138
179
|
|
|
180
|
+
// Always compute the shape-based items_path suggestion — UI offers it
|
|
181
|
+
// both on success ("you may also want this") and on failure ("apply
|
|
182
|
+
// this and retry"), so the user never has to guess.
|
|
183
|
+
const suggestedItemsPath = suggestItemsPath(parsed);
|
|
184
|
+
|
|
139
185
|
let items = pickPath(parsed, items_path || '');
|
|
140
186
|
// Same single-object-as-1-item-list logic as the scheduler, so the
|
|
141
187
|
// preview matches what runtime will see.
|
|
@@ -152,6 +198,7 @@ export async function POST(req: Request) {
|
|
|
152
198
|
? `items_path resolved to an empty array — no sample item available`
|
|
153
199
|
: `items_path "${items_path || '(empty)'}" did not resolve to an array or object`,
|
|
154
200
|
top_level_keys: topKeys,
|
|
201
|
+
suggested_items_path: suggestedItemsPath,
|
|
155
202
|
}, { status: 200 });
|
|
156
203
|
}
|
|
157
204
|
|
|
@@ -188,6 +235,8 @@ export async function POST(req: Request) {
|
|
|
188
235
|
sample_item: sampleItem,
|
|
189
236
|
item_keys: itemKeys,
|
|
190
237
|
suggested_template: suggestedTemplate,
|
|
238
|
+
suggested_items_path: suggestedItemsPath,
|
|
239
|
+
suggested_dedup_field: suggestDedupField(itemKeys),
|
|
191
240
|
total_items: items.length,
|
|
192
241
|
});
|
|
193
242
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/jobs/recipes
|
|
3
|
+
*
|
|
4
|
+
* GET — list installed recipes
|
|
5
|
+
* GET ?name=<name> — fetch a single recipe (UI form metadata)
|
|
6
|
+
* POST { action: 'instantiate', name, params } — create Job from recipe
|
|
7
|
+
* POST { action: 'upload', yaml } — save a user-supplied recipe yaml
|
|
8
|
+
* POST { action: 'delete', name } — delete a recipe file
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { NextResponse } from 'next/server';
|
|
12
|
+
import {
|
|
13
|
+
listRecipes,
|
|
14
|
+
getRecipe,
|
|
15
|
+
saveRecipeYaml,
|
|
16
|
+
deleteRecipe,
|
|
17
|
+
instantiateRecipe,
|
|
18
|
+
} from '@/lib/jobs/recipes';
|
|
19
|
+
|
|
20
|
+
export async function GET(req: Request) {
|
|
21
|
+
const { searchParams } = new URL(req.url);
|
|
22
|
+
const name = searchParams.get('name');
|
|
23
|
+
if (name) {
|
|
24
|
+
const r = getRecipe(name);
|
|
25
|
+
if (!r) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
|
26
|
+
return NextResponse.json({ recipe: r });
|
|
27
|
+
}
|
|
28
|
+
return NextResponse.json({ recipes: listRecipes() });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function POST(req: Request) {
|
|
32
|
+
let body: any;
|
|
33
|
+
try { body = await req.json(); }
|
|
34
|
+
catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
|
|
35
|
+
const action = body?.action;
|
|
36
|
+
|
|
37
|
+
if (action === 'instantiate') {
|
|
38
|
+
if (!body.name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
|
|
39
|
+
const r = instantiateRecipe(body.name, body.params || {});
|
|
40
|
+
if (!r.ok) return NextResponse.json(r, { status: 400 });
|
|
41
|
+
return NextResponse.json(r);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (action === 'upload') {
|
|
45
|
+
if (typeof body.yaml !== 'string' || !body.yaml.trim()) {
|
|
46
|
+
return NextResponse.json({ ok: false, error: 'yaml required' }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
const r = saveRecipeYaml(body.yaml);
|
|
49
|
+
return NextResponse.json(r, { status: r.ok ? 200 : 400 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === 'delete') {
|
|
53
|
+
if (!body.name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
|
|
54
|
+
const ok = deleteRecipe(body.name);
|
|
55
|
+
return NextResponse.json({ ok }, { status: ok ? 200 : 404 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/workflows/marketplace
|
|
3
|
+
*
|
|
4
|
+
* GET — list registry + installed recipes/pipelines (merged)
|
|
5
|
+
* POST { action: 'sync' } — pull registry.json from forge-workflow
|
|
6
|
+
* POST { action: 'install', kind, name } — download yaml → save to local dir
|
|
7
|
+
* POST { action: 'uninstall', kind, name } — remove the local yaml file
|
|
8
|
+
*
|
|
9
|
+
* `kind` is "recipe" | "pipeline". Built-in pipelines are protected
|
|
10
|
+
* elsewhere (lib/workflow-marketplace.ts excludes them from `installed`,
|
|
11
|
+
* and the pipelines delete-workflow route rejects them).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NextResponse } from 'next/server';
|
|
15
|
+
import {
|
|
16
|
+
syncMarketplace,
|
|
17
|
+
listMarketplace,
|
|
18
|
+
installFromMarketplace,
|
|
19
|
+
uninstallFromMarketplace,
|
|
20
|
+
type WorkflowKind,
|
|
21
|
+
} from '@/lib/workflow-marketplace';
|
|
22
|
+
|
|
23
|
+
export async function GET() {
|
|
24
|
+
return NextResponse.json(listMarketplace());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function POST(req: Request) {
|
|
28
|
+
let body: any;
|
|
29
|
+
try { body = await req.json(); }
|
|
30
|
+
catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
|
|
31
|
+
const action = body?.action;
|
|
32
|
+
|
|
33
|
+
if (action === 'sync') {
|
|
34
|
+
const r = await syncMarketplace();
|
|
35
|
+
return NextResponse.json(r, { status: r.ok ? 200 : 502 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (action === 'install' || action === 'uninstall') {
|
|
39
|
+
const kind = body.kind as WorkflowKind;
|
|
40
|
+
const name = body.name;
|
|
41
|
+
if (kind !== 'recipe' && kind !== 'pipeline') {
|
|
42
|
+
return NextResponse.json({ ok: false, error: 'kind must be "recipe" or "pipeline"' }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
if (!name) return NextResponse.json({ ok: false, error: 'name required' }, { status: 400 });
|
|
45
|
+
const r = action === 'install'
|
|
46
|
+
? await installFromMarketplace(kind, name, { target_name: body.target_name, overwrite: !!body.overwrite })
|
|
47
|
+
: uninstallFromMarketplace(kind, name);
|
|
48
|
+
return NextResponse.json(r, { status: r.ok ? 200 : 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return NextResponse.json({ ok: false, error: 'unknown action' }, { status: 400 });
|
|
52
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -145,6 +145,27 @@ if (!isStop) {
|
|
|
145
145
|
console.warn('[forge] Install Codex: https://github.com/openai/codex#installation');
|
|
146
146
|
console.warn('[forge] Or configure API-only profiles in Settings after login.');
|
|
147
147
|
}
|
|
148
|
+
|
|
149
|
+
// ── Optional helpers used by built-in workflows (mr-review-fix etc.).
|
|
150
|
+
// Warn-only: Forge starts fine without them, but specific Jobs/Pipelines
|
|
151
|
+
// will fail at the shell node if they're missing — better to flag here.
|
|
152
|
+
const optional = [
|
|
153
|
+
{ bin: 'jq', used: 'shell pipelines that parse JSON output' },
|
|
154
|
+
{ bin: 'glab', used: 'GitLab MR review / mr-review-fix workflow' },
|
|
155
|
+
];
|
|
156
|
+
const missing = optional.filter((o) => !has(o.bin));
|
|
157
|
+
if (missing.length) {
|
|
158
|
+
console.warn(`[forge] ⚠️ Optional CLIs missing: ${missing.map((m) => m.bin).join(', ')}`);
|
|
159
|
+
for (const m of missing) console.warn(`[forge] - ${m.bin}: used by ${m.used}`);
|
|
160
|
+
if (process.platform === 'darwin') {
|
|
161
|
+
console.warn(`[forge] Install with: brew install ${missing.map((m) => m.bin).join(' ')}`);
|
|
162
|
+
} else if (process.platform === 'linux') {
|
|
163
|
+
console.warn('[forge] Install via your distro\'s package manager.');
|
|
164
|
+
if (missing.some((m) => m.bin === 'glab')) {
|
|
165
|
+
console.warn('[forge] glab: see https://gitlab.com/gitlab-org/cli#installation');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
148
169
|
}
|
|
149
170
|
|
|
150
171
|
// ── Load <data-dir>/.env.local ──
|
|
@@ -459,6 +459,70 @@ export default function PipelineView({ onViewTask, focusPipelineId, onFocusHandl
|
|
|
459
459
|
const [editorIsConversation, setEditorIsConversation] = useState(false);
|
|
460
460
|
const [showImport, setShowImport] = useState(false);
|
|
461
461
|
const [importYaml, setImportYaml] = useState('');
|
|
462
|
+
const importFileRef = useRef<HTMLInputElement | null>(null);
|
|
463
|
+
|
|
464
|
+
// Marketplace state — pull pipeline templates from the configured
|
|
465
|
+
// workflowRepoUrl (default: aiwatching/forge-workflow). Stays collapsed
|
|
466
|
+
// until the user clicks the header button.
|
|
467
|
+
type MarketRow = {
|
|
468
|
+
kind: 'pipeline'; name: string; display_name: string; description?: string;
|
|
469
|
+
version: string; author?: string; tags?: string[];
|
|
470
|
+
source: 'registry' | 'local'; installed: boolean;
|
|
471
|
+
installed_version?: string; has_update?: boolean;
|
|
472
|
+
};
|
|
473
|
+
const [showMarketplace, setShowMarketplace] = useState(false);
|
|
474
|
+
const [marketRows, setMarketRows] = useState<MarketRow[] | null>(null);
|
|
475
|
+
const [marketBusy, setMarketBusy] = useState(false);
|
|
476
|
+
const [marketErr, setMarketErr] = useState<string>('');
|
|
477
|
+
|
|
478
|
+
async function fetchMarketplace() {
|
|
479
|
+
try {
|
|
480
|
+
const res = await fetch('/api/workflows/marketplace');
|
|
481
|
+
const data = await res.json();
|
|
482
|
+
setMarketRows((data.pipelines || []) as MarketRow[]);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
485
|
+
setMarketRows([]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function syncMarketplace() {
|
|
490
|
+
setMarketBusy(true);
|
|
491
|
+
setMarketErr('');
|
|
492
|
+
try {
|
|
493
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
494
|
+
method: 'POST',
|
|
495
|
+
headers: { 'Content-Type': 'application/json' },
|
|
496
|
+
body: JSON.stringify({ action: 'sync' }),
|
|
497
|
+
});
|
|
498
|
+
const data = await res.json();
|
|
499
|
+
if (!data.ok) setMarketErr(data.error || 'sync failed');
|
|
500
|
+
await fetchMarketplace();
|
|
501
|
+
} catch (e) {
|
|
502
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
503
|
+
} finally {
|
|
504
|
+
setMarketBusy(false);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function installFromMarketplace(name: string) {
|
|
509
|
+
setMarketBusy(true);
|
|
510
|
+
setMarketErr('');
|
|
511
|
+
try {
|
|
512
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: { 'Content-Type': 'application/json' },
|
|
515
|
+
body: JSON.stringify({ action: 'install', kind: 'pipeline', name }),
|
|
516
|
+
});
|
|
517
|
+
const data = await res.json();
|
|
518
|
+
if (!data.ok) { setMarketErr(data.error || 'install failed'); return; }
|
|
519
|
+
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
522
|
+
} finally {
|
|
523
|
+
setMarketBusy(false);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
462
526
|
const [agents, setAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
|
|
463
527
|
|
|
464
528
|
// "Load older runs" state, per-workflow. The initial /api/pipelines fetch
|
|
@@ -694,19 +758,155 @@ initial_prompt: "{{input.task}}"
|
|
|
694
758
|
onClick={() => { setImportYaml(generateConversationTemplate()); setShowImport(true); }}
|
|
695
759
|
className="text-[9px] text-purple-400 hover:underline"
|
|
696
760
|
>+ Conversation</button>
|
|
761
|
+
<button
|
|
762
|
+
onClick={() => {
|
|
763
|
+
const next = !showMarketplace;
|
|
764
|
+
setShowMarketplace(next);
|
|
765
|
+
if (next && marketRows == null) void fetchMarketplace();
|
|
766
|
+
}}
|
|
767
|
+
className="text-[9px] text-blue-400 hover:underline"
|
|
768
|
+
title="Import a workflow from the marketplace as a new local copy"
|
|
769
|
+
>+ From marketplace</button>
|
|
697
770
|
</div>
|
|
698
771
|
|
|
772
|
+
{/* Marketplace import — each pipeline in the registry can be
|
|
773
|
+
cloned to as many local copies as you want, each with a
|
|
774
|
+
unique name. The local copy is fully independent of the
|
|
775
|
+
upstream — edits don't propagate either way. */}
|
|
776
|
+
{showMarketplace && (
|
|
777
|
+
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
778
|
+
<div className="flex items-center gap-2 mb-1">
|
|
779
|
+
<span className="text-[10px] font-semibold text-[var(--text-secondary)] flex-1">
|
|
780
|
+
Pipelines from forge-workflow
|
|
781
|
+
</span>
|
|
782
|
+
<button
|
|
783
|
+
onClick={() => void syncMarketplace()}
|
|
784
|
+
disabled={marketBusy}
|
|
785
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
786
|
+
>{marketBusy ? '…' : 'Sync'}</button>
|
|
787
|
+
</div>
|
|
788
|
+
{marketRows == null ? (
|
|
789
|
+
<div className="text-[10px] text-[var(--text-secondary)]">Loading…</div>
|
|
790
|
+
) : marketRows.length === 0 ? (
|
|
791
|
+
<div className="text-[10px] text-[var(--text-secondary)]">
|
|
792
|
+
Nothing yet — click <b>Sync</b> to pull the registry.
|
|
793
|
+
</div>
|
|
794
|
+
) : (
|
|
795
|
+
<div className="space-y-1.5 max-h-80 overflow-y-auto">
|
|
796
|
+
{marketRows.map((r) => (
|
|
797
|
+
<div key={r.name} className="border border-[var(--border)]/60 rounded p-2 bg-[var(--bg-tertiary)]/40">
|
|
798
|
+
<div className="flex items-baseline gap-1.5">
|
|
799
|
+
<span className="text-[10px] font-semibold text-[var(--text-primary)] truncate flex-1">{r.display_name}</span>
|
|
800
|
+
<span className="text-[8px] text-[var(--text-secondary)] font-mono">v{r.version}</span>
|
|
801
|
+
{r.installed && (
|
|
802
|
+
<span className="text-[8px] text-[var(--green)]">installed</span>
|
|
803
|
+
)}
|
|
804
|
+
{r.installed && (
|
|
805
|
+
<button
|
|
806
|
+
onClick={async () => {
|
|
807
|
+
if (!confirm(`Reinstall "${r.name}" from registry (v${r.version})?\n\nThis OVERWRITES your local copy at ~/.forge/data/flows/${r.name}.yaml — any local edits will be lost.`)) return;
|
|
808
|
+
setMarketBusy(true);
|
|
809
|
+
setMarketErr('');
|
|
810
|
+
try {
|
|
811
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
812
|
+
method: 'POST',
|
|
813
|
+
headers: { 'Content-Type': 'application/json' },
|
|
814
|
+
body: JSON.stringify({ action: 'install', kind: 'pipeline', name: r.name, target_name: r.name, overwrite: true }),
|
|
815
|
+
});
|
|
816
|
+
const data = await res.json();
|
|
817
|
+
if (!data.ok) { setMarketErr(data.error || 'reinstall failed'); return; }
|
|
818
|
+
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
819
|
+
alert(`"${r.name}" reinstalled from registry (v${r.version}).`);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
822
|
+
} finally { setMarketBusy(false); }
|
|
823
|
+
}}
|
|
824
|
+
disabled={marketBusy}
|
|
825
|
+
className="text-[8px] px-2 py-0.5 border border-[var(--yellow)] text-[var(--yellow)] rounded hover:bg-[var(--yellow)] hover:text-black disabled:opacity-50"
|
|
826
|
+
title="Overwrite the local copy with the latest from registry"
|
|
827
|
+
>Reinstall</button>
|
|
828
|
+
)}
|
|
829
|
+
<button
|
|
830
|
+
onClick={async () => {
|
|
831
|
+
const installed = r.installed;
|
|
832
|
+
const defaultName = installed ? `${r.name}-2` : r.name;
|
|
833
|
+
const localName = window.prompt(
|
|
834
|
+
installed
|
|
835
|
+
? `"${r.name}" is already installed locally. Pick a NEW name for an additional copy (or click Reinstall to overwrite):`
|
|
836
|
+
: `Create a local copy of "${r.name}". Pick a name for your copy:`,
|
|
837
|
+
defaultName,
|
|
838
|
+
);
|
|
839
|
+
if (!localName) return;
|
|
840
|
+
const trimmed = localName.trim();
|
|
841
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(trimmed)) {
|
|
842
|
+
alert('Name must be lowercase alphanumerics + hyphens/underscores');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
setMarketBusy(true);
|
|
846
|
+
setMarketErr('');
|
|
847
|
+
try {
|
|
848
|
+
const res = await fetch('/api/workflows/marketplace', {
|
|
849
|
+
method: 'POST',
|
|
850
|
+
headers: { 'Content-Type': 'application/json' },
|
|
851
|
+
body: JSON.stringify({ action: 'install', kind: 'pipeline', name: r.name, target_name: trimmed }),
|
|
852
|
+
});
|
|
853
|
+
const data = await res.json();
|
|
854
|
+
if (!data.ok) { setMarketErr(data.error || 'import failed'); return; }
|
|
855
|
+
await Promise.all([fetchMarketplace(), fetchData()]);
|
|
856
|
+
alert(`Imported as "${data.installed_as}". Open it from the Workflows list.`);
|
|
857
|
+
} catch (e) {
|
|
858
|
+
setMarketErr(e instanceof Error ? e.message : String(e));
|
|
859
|
+
} finally { setMarketBusy(false); }
|
|
860
|
+
}}
|
|
861
|
+
disabled={marketBusy}
|
|
862
|
+
className="text-[8px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
863
|
+
>{r.installed ? 'Import as new copy…' : 'Import as…'}</button>
|
|
864
|
+
</div>
|
|
865
|
+
{r.description && (
|
|
866
|
+
<p className="text-[9px] text-[var(--text-secondary)] mt-1 line-clamp-3">{r.description}</p>
|
|
867
|
+
)}
|
|
868
|
+
{r.tags && r.tags.length > 0 && (
|
|
869
|
+
<div className="text-[8px] text-[var(--text-secondary)] mt-1">
|
|
870
|
+
{r.tags.map((t) => `#${t}`).join(' ')}
|
|
871
|
+
</div>
|
|
872
|
+
)}
|
|
873
|
+
</div>
|
|
874
|
+
))}
|
|
875
|
+
</div>
|
|
876
|
+
)}
|
|
877
|
+
{marketErr && (
|
|
878
|
+
<div className="text-[10px] text-[var(--red)]">{marketErr}</div>
|
|
879
|
+
)}
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
|
|
699
883
|
{/* Import form */}
|
|
700
884
|
{showImport && (
|
|
701
885
|
<div className="p-3 border-b border-[var(--border)] space-y-2">
|
|
886
|
+
<input
|
|
887
|
+
ref={importFileRef}
|
|
888
|
+
type="file"
|
|
889
|
+
accept=".yaml,.yml"
|
|
890
|
+
className="hidden"
|
|
891
|
+
onChange={(e) => {
|
|
892
|
+
const f = e.target.files?.[0];
|
|
893
|
+
e.target.value = '';
|
|
894
|
+
if (!f) return;
|
|
895
|
+
f.text().then(setImportYaml).catch(() => alert('Failed to read file'));
|
|
896
|
+
}}
|
|
897
|
+
/>
|
|
702
898
|
<textarea
|
|
703
899
|
value={importYaml}
|
|
704
900
|
onChange={e => setImportYaml(e.target.value)}
|
|
705
|
-
placeholder="Paste YAML workflow here
|
|
901
|
+
placeholder="Paste YAML workflow here, or pick a .yaml file →"
|
|
706
902
|
className="w-full h-40 text-xs font-mono bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-2 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
707
903
|
spellCheck={false}
|
|
708
904
|
/>
|
|
709
905
|
<div className="flex gap-2">
|
|
906
|
+
<button
|
|
907
|
+
onClick={() => importFileRef.current?.click()}
|
|
908
|
+
className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
|
|
909
|
+
>Pick file…</button>
|
|
710
910
|
<button
|
|
711
911
|
onClick={async () => {
|
|
712
912
|
if (!importYaml.trim()) return;
|
|
@@ -850,6 +1050,45 @@ initial_prompt: "{{input.task}}"
|
|
|
850
1050
|
className="text-[8px] text-green-400 hover:underline shrink-0"
|
|
851
1051
|
title={w.builtin ? 'View YAML' : 'Edit'}
|
|
852
1052
|
>{w.builtin ? 'View' : 'Edit'}</button>
|
|
1053
|
+
<button
|
|
1054
|
+
onClick={async (e) => {
|
|
1055
|
+
e.stopPropagation();
|
|
1056
|
+
try {
|
|
1057
|
+
const res = await fetch(`/api/pipelines?type=workflow-yaml&name=${encodeURIComponent(w.name)}`);
|
|
1058
|
+
const data = await res.json();
|
|
1059
|
+
if (!data.yaml) { alert('Failed to load yaml'); return; }
|
|
1060
|
+
// Trigger a file download — share = send this .yaml file.
|
|
1061
|
+
const blob = new Blob([data.yaml], { type: 'application/x-yaml' });
|
|
1062
|
+
const url = URL.createObjectURL(blob);
|
|
1063
|
+
const a = document.createElement('a');
|
|
1064
|
+
a.href = url; a.download = `${w.name}.yaml`; a.click();
|
|
1065
|
+
URL.revokeObjectURL(url);
|
|
1066
|
+
} catch { alert('Export failed'); }
|
|
1067
|
+
}}
|
|
1068
|
+
className="text-[8px] text-blue-400 hover:underline shrink-0"
|
|
1069
|
+
title="Download YAML to share"
|
|
1070
|
+
>Export</button>
|
|
1071
|
+
{!w.builtin && (
|
|
1072
|
+
<button
|
|
1073
|
+
onClick={async (e) => {
|
|
1074
|
+
e.stopPropagation();
|
|
1075
|
+
if (!confirm(`Delete workflow "${w.name}"? This removes the .yaml file from disk.`)) return;
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await fetch('/api/pipelines', {
|
|
1078
|
+
method: 'POST',
|
|
1079
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1080
|
+
body: JSON.stringify({ action: 'delete-workflow', name: w.name }),
|
|
1081
|
+
});
|
|
1082
|
+
const data = await res.json();
|
|
1083
|
+
if (!res.ok || data.error) { alert(`Delete failed: ${data.error || res.status}`); return; }
|
|
1084
|
+
if (activeWorkflow === w.name) setActiveWorkflow(null);
|
|
1085
|
+
fetchData();
|
|
1086
|
+
} catch { alert('Delete failed'); }
|
|
1087
|
+
}}
|
|
1088
|
+
className="text-[8px] text-[var(--red)] hover:underline shrink-0"
|
|
1089
|
+
title="Delete this user workflow"
|
|
1090
|
+
>Del</button>
|
|
1091
|
+
)}
|
|
853
1092
|
</div>
|
|
854
1093
|
{/* Execution history for this workflow */}
|
|
855
1094
|
{isActive && (() => {
|
|
@@ -1018,11 +1257,15 @@ initial_prompt: "{{input.task}}"
|
|
|
1018
1257
|
Started: {new Date(selectedPipeline.createdAt).toLocaleString()}
|
|
1019
1258
|
{selectedPipeline.completedAt && ` · Completed: ${new Date(selectedPipeline.completedAt).toLocaleString()}`}
|
|
1020
1259
|
</div>
|
|
1021
|
-
{Object.keys(selectedPipeline.input).length > 0 && (
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1260
|
+
{Object.keys(selectedPipeline.input).length > 0 && (() => {
|
|
1261
|
+
const inputStr = Object.entries(selectedPipeline.input).map(([k, v]) => `${k}="${v}"`).join(', ');
|
|
1262
|
+
return (
|
|
1263
|
+
<div
|
|
1264
|
+
className="text-[9px] text-[var(--text-secondary)] mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap break-words"
|
|
1265
|
+
title={inputStr}
|
|
1266
|
+
>Input: {inputStr}</div>
|
|
1267
|
+
);
|
|
1268
|
+
})()}
|
|
1026
1269
|
</div>
|
|
1027
1270
|
|
|
1028
1271
|
{/* Conversation or DAG visualization */}
|
|
@@ -1088,7 +1331,12 @@ initial_prompt: "{{input.task}}"
|
|
|
1088
1331
|
>{w.builtin ? 'View YAML' : 'Edit'}</button>
|
|
1089
1332
|
</div>
|
|
1090
1333
|
</div>
|
|
1091
|
-
{w.description &&
|
|
1334
|
+
{w.description && (
|
|
1335
|
+
<p
|
|
1336
|
+
className="text-[10px] text-[var(--text-secondary)] mt-1 max-h-20 overflow-y-auto whitespace-pre-wrap break-words"
|
|
1337
|
+
title={w.description}
|
|
1338
|
+
>{w.description}</p>
|
|
1339
|
+
)}
|
|
1092
1340
|
{Object.keys(w.input).length > 0 && (
|
|
1093
1341
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
1094
1342
|
{Object.entries(w.input).map(([k, v]) => (
|