@aion0/forge 0.6.1 → 0.8.0
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/.forge/mcp.json +8 -0
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
- package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
- package/CLAUDE.md +2 -2
- package/RELEASE_NOTES.md +101 -5
- package/app/api/auth/check/route.ts +18 -0
- package/app/api/browser-bridge/route.ts +70 -0
- package/app/api/chat/sessions/[id]/events/route.ts +17 -0
- package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
- package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
- package/app/api/chat/sessions/[id]/route.ts +23 -0
- package/app/api/chat/sessions/route.ts +12 -0
- package/app/api/chat/temper-ping/route.ts +18 -0
- package/app/api/chat-proxy/[...path]/route.ts +83 -0
- package/app/api/connector-tool/route.ts +38 -0
- package/app/api/connectors/[id]/settings/route.ts +112 -0
- package/app/api/connectors/route.ts +108 -0
- package/app/api/health/tools/route.ts +14 -0
- package/app/api/issue-scanner-gitlab/route.ts +95 -0
- package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
- package/app/api/jobs/[id]/route.ts +31 -0
- package/app/api/jobs/[id]/run/route.ts +44 -0
- package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
- package/app/api/jobs/[id]/runs/route.ts +15 -0
- package/app/api/jobs/preview/route.ts +193 -0
- package/app/api/jobs/route.ts +36 -0
- package/app/api/notify/test/route.ts +39 -7
- package/app/api/pipelines/[id]/route.ts +10 -1
- package/app/api/pipelines/route.ts +16 -2
- package/app/api/plugins/route.ts +40 -8
- package/app/api/project-sessions/route.ts +50 -10
- package/app/api/settings/route.ts +13 -0
- package/app/chat/page.tsx +531 -0
- package/bin/forge-server.mjs +3 -1
- package/cli/chat.ts +283 -0
- package/cli/jobs.ts +176 -0
- package/cli/mw.ts +28 -1
- package/cli/worktree.ts +245 -0
- package/components/ConnectorsPanel.tsx +275 -0
- package/components/Dashboard.tsx +90 -37
- package/components/JobsView.tsx +361 -0
- package/components/LogViewer.tsx +12 -2
- package/components/PipelineView.tsx +275 -56
- package/components/PluginsPanel.tsx +3 -1
- package/components/SettingsModal.tsx +229 -40
- package/components/SkillsPanel.tsx +12 -4
- package/components/TerminalLauncher.tsx +3 -1
- package/components/WebTerminal.tsx +32 -9
- package/components/WorkspaceView.tsx +18 -10
- package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
- package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
- package/docs/Implementation-Plan-Browser-Agent.md +487 -0
- package/docs/Jobs-Design.md +240 -0
- package/docs/LOCAL-DEPLOY.md +3 -3
- package/docs/RFC-Browser-Connectors.md +509 -0
- package/lib/agents/index.ts +44 -6
- package/lib/agents/types.ts +1 -1
- package/lib/browser-bridge-standalone.ts +317 -0
- package/lib/builtin-plugins/github-api.yaml +93 -0
- package/lib/builtin-plugins/gitlab.yaml +860 -0
- package/lib/builtin-plugins/mantis.probe.js +176 -0
- package/lib/builtin-plugins/mantis.yaml +964 -0
- package/lib/builtin-plugins/pmdb.yaml +178 -0
- package/lib/builtin-plugins/teams.yaml +913 -0
- package/lib/chat/__test__/smoke.ts +30 -0
- package/lib/chat/agent-loop.ts +523 -0
- package/lib/chat/bridge-client.ts +59 -0
- package/lib/chat/llm/anthropic.ts +99 -0
- package/lib/chat/llm/index.ts +20 -0
- package/lib/chat/llm/openai.ts +215 -0
- package/lib/chat/llm/types.ts +42 -0
- package/lib/chat/local-memory.ts +300 -0
- package/lib/chat/memory-store.ts +87 -0
- package/lib/chat/memory-tools.ts +157 -0
- package/lib/chat/protocols/http.ts +118 -0
- package/lib/chat/protocols/shell.ts +101 -0
- package/lib/chat/proxy.ts +51 -0
- package/lib/chat/session-store.ts +272 -0
- package/lib/chat/telegram-bridge.ts +276 -0
- package/lib/chat/temper.ts +281 -0
- package/lib/chat/tool-dispatcher.ts +190 -0
- package/lib/chat/types.ts +50 -0
- package/lib/chat-standalone.ts +286 -0
- package/lib/crypto.ts +1 -1
- package/lib/health.ts +131 -0
- package/lib/help-docs/00-overview.md +2 -1
- package/lib/help-docs/01-settings.md +46 -25
- package/lib/help-docs/07-projects.md +1 -1
- package/lib/help-docs/10-troubleshooting.md +10 -2
- package/lib/help-docs/16-gitlab-autofix.md +114 -0
- package/lib/help-docs/17-connectors.md +322 -0
- package/lib/help-docs/18-chrome-mcp.md +134 -0
- package/lib/help-docs/19-jobs.md +140 -0
- package/lib/help-docs/20-mantis-bug-fix.md +115 -0
- package/lib/help-docs/CLAUDE.md +10 -0
- package/lib/init.ts +137 -50
- package/lib/iso-time.ts +30 -0
- package/lib/issue-scanner-gitlab.ts +281 -0
- package/lib/jobs/dispatcher.ts +217 -0
- package/lib/jobs/scheduler.ts +334 -0
- package/lib/jobs/store.ts +319 -0
- package/lib/jobs/types.ts +117 -0
- package/lib/pipeline-scheduler.ts +1 -6
- package/lib/pipeline.ts +790 -10
- package/lib/plugins/registry.ts +133 -8
- package/lib/plugins/templates.ts +83 -0
- package/lib/plugins/types.ts +140 -1
- package/lib/session-watcher.ts +36 -10
- package/lib/settings.ts +65 -33
- package/lib/skills.ts +3 -1
- package/lib/task-manager.ts +50 -22
- package/lib/telegram-bot.ts +71 -0
- package/lib/terminal-standalone.ts +58 -36
- package/lib/workspace/orchestrator.ts +1 -0
- package/middleware.ts +10 -0
- package/package.json +3 -2
- package/scripts/bench/README.md +1 -1
- package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
- package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
- package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
- package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
- package/src/core/db/database.ts +21 -12
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/jobs/<id> { job, recent_runs: [..20..] }
|
|
3
|
+
* PATCH /api/jobs/<id> partial update
|
|
4
|
+
* DELETE /api/jobs/<id> cascade
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { NextResponse } from 'next/server';
|
|
8
|
+
import { getJob, updateJob, deleteJob, listRuns } from '@/lib/jobs/store';
|
|
9
|
+
|
|
10
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
11
|
+
const { id } = await params;
|
|
12
|
+
const job = getJob(id);
|
|
13
|
+
if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
|
|
14
|
+
return NextResponse.json({ job, recent_runs: listRuns(id, 20) });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
18
|
+
const { id } = await params;
|
|
19
|
+
const job = getJob(id);
|
|
20
|
+
if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
|
|
21
|
+
let body: any = {};
|
|
22
|
+
try { body = await req.json(); } catch {}
|
|
23
|
+
const ok = updateJob(id, body);
|
|
24
|
+
return NextResponse.json({ ok, job: getJob(id) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
28
|
+
const { id } = await params;
|
|
29
|
+
const ok = deleteJob(id);
|
|
30
|
+
return NextResponse.json({ ok });
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/jobs/<id>/run — manually fire one tick.
|
|
3
|
+
*
|
|
4
|
+
* Inserts the run row synchronously, returns its id (202), and runs the
|
|
5
|
+
* actual work in the background. Poll GET /api/jobs/<id>/runs/<run_id>
|
|
6
|
+
* (or /api/jobs/<id>) to observe completion.
|
|
7
|
+
*
|
|
8
|
+
* Query params:
|
|
9
|
+
* reset_dedup=1 wipe job_seen before running. Useful for "force re-dispatch
|
|
10
|
+
* the same items I tested with last time" while iterating
|
|
11
|
+
* on a pipeline. Recorded as note on the run for audit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NextResponse } from 'next/server';
|
|
15
|
+
import { prepareRun, executeRun } from '@/lib/jobs/scheduler';
|
|
16
|
+
import { resetDedup } from '@/lib/jobs/store';
|
|
17
|
+
|
|
18
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
19
|
+
const { id } = await params;
|
|
20
|
+
const url = new URL(req.url);
|
|
21
|
+
const shouldReset = url.searchParams.get('reset_dedup') === '1';
|
|
22
|
+
let removedDedupKeys = 0;
|
|
23
|
+
if (shouldReset) {
|
|
24
|
+
try { removedDedupKeys = resetDedup(id); }
|
|
25
|
+
catch (e) {
|
|
26
|
+
return NextResponse.json({ error: 'reset_dedup failed: ' + (e as Error).message }, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let prepared;
|
|
30
|
+
try {
|
|
31
|
+
prepared = prepareRun(id, 'manual');
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return NextResponse.json({ error: (e as Error).message }, { status: 404 });
|
|
34
|
+
}
|
|
35
|
+
void executeRun(prepared.job, prepared.runId).catch((err) => {
|
|
36
|
+
console.error('[jobs] manual run crashed', err);
|
|
37
|
+
});
|
|
38
|
+
return NextResponse.json({
|
|
39
|
+
accepted: true,
|
|
40
|
+
run_id: prepared.runId,
|
|
41
|
+
dedup_reset: shouldReset,
|
|
42
|
+
removed_dedup_keys: shouldReset ? removedDedupKeys : undefined,
|
|
43
|
+
}, { status: 202 });
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/jobs/<id>/runs/<runId> — { run, dispatches: [...] }
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NextResponse } from 'next/server';
|
|
6
|
+
import { getJob, getRun, listDispatches } from '@/lib/jobs/store';
|
|
7
|
+
|
|
8
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string; runId: string }> }) {
|
|
9
|
+
const { id, runId } = await params;
|
|
10
|
+
const job = getJob(id);
|
|
11
|
+
if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
|
|
12
|
+
const run = getRun(runId);
|
|
13
|
+
if (!run || run.job_id !== id) return NextResponse.json({ error: 'run not found' }, { status: 404 });
|
|
14
|
+
return NextResponse.json({ run, dispatches: listDispatches(runId) });
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/jobs/<id>/runs?limit=20 — list runs for a job.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NextResponse } from 'next/server';
|
|
6
|
+
import { getJob, listRuns } from '@/lib/jobs/store';
|
|
7
|
+
|
|
8
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
|
+
const { id } = await params;
|
|
10
|
+
const job = getJob(id);
|
|
11
|
+
if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
|
|
12
|
+
const url = new URL(req.url);
|
|
13
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 20)));
|
|
14
|
+
return NextResponse.json({ runs: listRuns(id, limit) });
|
|
15
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/jobs/preview
|
|
3
|
+
*
|
|
4
|
+
* Dry-run a Job's source_tool with its source_input and return the first
|
|
5
|
+
* parsed item. The Jobs editor uses this to auto-build input_template:
|
|
6
|
+
* "given the bug Mantis just returned, what {{item.X}} expression maps
|
|
7
|
+
* each pipeline input field?" — eliminating the friction where users
|
|
8
|
+
* had to know in advance that get_bug returns `id` not `bug_id` etc.
|
|
9
|
+
*
|
|
10
|
+
* NO dispatch happens. NO dedup mutation. NO job row required (this works
|
|
11
|
+
* before the Job is saved, that's the whole point).
|
|
12
|
+
*
|
|
13
|
+
* Request body:
|
|
14
|
+
* {
|
|
15
|
+
* source_connector: string,
|
|
16
|
+
* source_tool: string,
|
|
17
|
+
* source_input?: object,
|
|
18
|
+
* items_path?: string, // empty / dotted path
|
|
19
|
+
* pipeline_inputs?: string[] // optional: workflow's input field names —
|
|
20
|
+
* // if given, response includes a suggested
|
|
21
|
+
* // input_template with fuzzy-matched mappings
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Response:
|
|
25
|
+
* {
|
|
26
|
+
* ok: true,
|
|
27
|
+
* sample_item: object, // first item from the connector
|
|
28
|
+
* item_keys: string[], // top-level keys on sample_item
|
|
29
|
+
* suggested_template?: Record<string,string> // pipeline_input → "{{item.X}}"
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* or { ok: false, error, ...debug } on failure
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { NextResponse } from 'next/server';
|
|
36
|
+
import { dispatchTool } from '@/lib/chat/tool-dispatcher';
|
|
37
|
+
|
|
38
|
+
function pickPath(obj: unknown, path: string): unknown {
|
|
39
|
+
if (!path) return obj;
|
|
40
|
+
const parts = path.split('.');
|
|
41
|
+
let cur: any = obj;
|
|
42
|
+
for (const p of parts) {
|
|
43
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
44
|
+
cur = cur[p];
|
|
45
|
+
}
|
|
46
|
+
return cur;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Best-effort match a workflow input name to a key on the sample item.
|
|
51
|
+
* The mantis case (bug_id ← id, base_branch ← nothing) drove this list:
|
|
52
|
+
* - Exact match wins.
|
|
53
|
+
* - Strip _id / Id / ID suffixes both sides → check.
|
|
54
|
+
* - 'description' fuzz: desc, body, content all work.
|
|
55
|
+
* - 'summary' fuzz: title, name.
|
|
56
|
+
* Returns the matching item-key or null. NEVER guesses across unrelated names.
|
|
57
|
+
*/
|
|
58
|
+
function suggestMapping(inputName: string, itemKeys: string[]): string | null {
|
|
59
|
+
const lc = inputName.toLowerCase();
|
|
60
|
+
const lcMap = new Map(itemKeys.map(k => [k.toLowerCase(), k]));
|
|
61
|
+
|
|
62
|
+
// 1. Exact match.
|
|
63
|
+
if (lcMap.has(lc)) return lcMap.get(lc)!;
|
|
64
|
+
|
|
65
|
+
// 2. ID-shaped fields. The interesting cases:
|
|
66
|
+
// - pipeline input "bug_id" + item has "id" → match "id"
|
|
67
|
+
// - pipeline input "bug_id" + item has "bug" → match "bug"
|
|
68
|
+
// - pipeline input "bug_id" + item has "bugId" → match "bugId"
|
|
69
|
+
// - pipeline input "id" + item has "issue_id" → match "issue_id"
|
|
70
|
+
const endsWithId = /_?id$/i.test(lc);
|
|
71
|
+
const stripped = lc.replace(/_?id$/i, '');
|
|
72
|
+
if (endsWithId) {
|
|
73
|
+
if (lcMap.has('id')) return lcMap.get('id')!; // ← the missing case
|
|
74
|
+
if (stripped && lcMap.has(stripped)) return lcMap.get(stripped)!;
|
|
75
|
+
if (stripped && lcMap.has(stripped + '_id')) return lcMap.get(stripped + '_id')!;
|
|
76
|
+
} else if (lc === 'id') {
|
|
77
|
+
for (const k of itemKeys) {
|
|
78
|
+
if (/_?id$/i.test(k.toLowerCase())) return k;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Synonym table — short, targeted. Add cases when a real connector
|
|
83
|
+
// surfaces them, not speculatively.
|
|
84
|
+
const synonyms: Record<string, string[]> = {
|
|
85
|
+
summary: ['title', 'name', 'subject'],
|
|
86
|
+
description: ['body', 'content', 'desc', 'text'],
|
|
87
|
+
assignee: ['handler', 'assigned_to', 'owner'],
|
|
88
|
+
reporter: ['author', 'created_by', 'submitted_by'],
|
|
89
|
+
priority: ['severity'],
|
|
90
|
+
category: ['keywords', 'labels', 'tags'], // mantis: 'keywords' col
|
|
91
|
+
url: ['web_url', 'link', 'href'],
|
|
92
|
+
};
|
|
93
|
+
for (const alt of synonyms[lc] || []) {
|
|
94
|
+
if (lcMap.has(alt)) return lcMap.get(alt)!;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function POST(req: Request) {
|
|
100
|
+
let body: any;
|
|
101
|
+
try { body = await req.json(); }
|
|
102
|
+
catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
|
|
103
|
+
|
|
104
|
+
const { source_connector, source_tool, source_input, items_path, pipeline_inputs } = body || {};
|
|
105
|
+
if (!source_connector || !source_tool) {
|
|
106
|
+
return NextResponse.json({ ok: false, error: 'source_connector and source_tool are required' }, { status: 400 });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const callName = `${source_connector}.${source_tool}`;
|
|
110
|
+
let toolResult;
|
|
111
|
+
try {
|
|
112
|
+
toolResult = await dispatchTool({
|
|
113
|
+
id: `jobs-preview-${Date.now()}`,
|
|
114
|
+
name: callName,
|
|
115
|
+
input: source_input || {},
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return NextResponse.json({ ok: false, error: `connector call threw: ${(e as Error).message}` }, { status: 500 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (toolResult.is_error) {
|
|
122
|
+
return NextResponse.json({
|
|
123
|
+
ok: false,
|
|
124
|
+
error: `connector returned is_error=true`,
|
|
125
|
+
raw_preview: toolResult.content.slice(0, 800),
|
|
126
|
+
}, { status: 200 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let parsed: unknown;
|
|
130
|
+
try { parsed = JSON.parse(toolResult.content); }
|
|
131
|
+
catch {
|
|
132
|
+
return NextResponse.json({
|
|
133
|
+
ok: false,
|
|
134
|
+
error: 'connector returned non-JSON content',
|
|
135
|
+
raw_preview: toolResult.content.slice(0, 800),
|
|
136
|
+
}, { status: 200 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let items = pickPath(parsed, items_path || '');
|
|
140
|
+
// Same single-object-as-1-item-list logic as the scheduler, so the
|
|
141
|
+
// preview matches what runtime will see.
|
|
142
|
+
if (items && typeof items === 'object' && !Array.isArray(items)) {
|
|
143
|
+
items = [items];
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
146
|
+
const topKeys = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
147
|
+
? Object.keys(parsed as Record<string, unknown>)
|
|
148
|
+
: [];
|
|
149
|
+
return NextResponse.json({
|
|
150
|
+
ok: false,
|
|
151
|
+
error: Array.isArray(items)
|
|
152
|
+
? `items_path resolved to an empty array — no sample item available`
|
|
153
|
+
: `items_path "${items_path || '(empty)'}" did not resolve to an array or object`,
|
|
154
|
+
top_level_keys: topKeys,
|
|
155
|
+
}, { status: 200 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sampleItem = items[0] as Record<string, unknown>;
|
|
159
|
+
// Union of keys across ALL returned items, not just items[0]. Mantis
|
|
160
|
+
// bug rows occasionally omit fields when blank — e.g. a bug with no
|
|
161
|
+
// fix_schedule won't have that key on its object — so basing the
|
|
162
|
+
// template on a single sample misses real-world fields. Scan up to
|
|
163
|
+
// 20 items (covers >99% of typical job batches).
|
|
164
|
+
const keySet = new Set<string>();
|
|
165
|
+
const sampleCount = Math.min(items.length, 20);
|
|
166
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
167
|
+
const it = items[i];
|
|
168
|
+
if (it && typeof it === 'object' && !Array.isArray(it)) {
|
|
169
|
+
for (const k of Object.keys(it as Record<string, unknown>)) keySet.add(k);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const itemKeys = Array.from(keySet);
|
|
173
|
+
|
|
174
|
+
let suggestedTemplate: Record<string, string> | undefined;
|
|
175
|
+
if (Array.isArray(pipeline_inputs) && pipeline_inputs.length > 0) {
|
|
176
|
+
suggestedTemplate = {};
|
|
177
|
+
for (const inputName of pipeline_inputs) {
|
|
178
|
+
if (typeof inputName !== 'string') continue;
|
|
179
|
+
const match = suggestMapping(inputName, itemKeys);
|
|
180
|
+
// If no fuzzy match found, leave it blank so the user knows it needs
|
|
181
|
+
// a constant (e.g. base_branch). NEVER guess across unrelated names.
|
|
182
|
+
suggestedTemplate[inputName] = match ? `{{item.${match}}}` : '';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return NextResponse.json({
|
|
187
|
+
ok: true,
|
|
188
|
+
sample_item: sampleItem,
|
|
189
|
+
item_keys: itemKeys,
|
|
190
|
+
suggested_template: suggestedTemplate,
|
|
191
|
+
total_items: items.length,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/jobs list all jobs
|
|
3
|
+
* POST /api/jobs create a job
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextResponse } from 'next/server';
|
|
7
|
+
import { listJobs, createJob } from '@/lib/jobs/store';
|
|
8
|
+
|
|
9
|
+
export async function GET() {
|
|
10
|
+
return NextResponse.json({ jobs: listJobs() });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function POST(req: Request) {
|
|
14
|
+
let body: any = {};
|
|
15
|
+
try { body = await req.json(); } catch {}
|
|
16
|
+
if (!body?.name || !body?.source_connector || !body?.source_tool || !body?.dedup_field || !body?.dispatch_type || !body?.dispatch_params) {
|
|
17
|
+
return NextResponse.json({ error: 'name, source_connector, source_tool, dedup_field, dispatch_type, dispatch_params are required' }, { status: 400 });
|
|
18
|
+
}
|
|
19
|
+
if (body.dispatch_type !== 'pipeline' && body.dispatch_type !== 'chat') {
|
|
20
|
+
return NextResponse.json({ error: "dispatch_type must be 'pipeline' or 'chat'" }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
const job = createJob({
|
|
23
|
+
name: String(body.name),
|
|
24
|
+
enabled: body.enabled !== false,
|
|
25
|
+
schedule_interval_minutes: Number(body.schedule_interval_minutes) || 30,
|
|
26
|
+
source_connector: String(body.source_connector),
|
|
27
|
+
source_tool: String(body.source_tool),
|
|
28
|
+
source_input: body.source_input || {},
|
|
29
|
+
items_path: body.items_path || '',
|
|
30
|
+
dedup_field: String(body.dedup_field),
|
|
31
|
+
dispatch_type: body.dispatch_type,
|
|
32
|
+
dispatch_params: body.dispatch_params,
|
|
33
|
+
mark_existing_as_seen: body.mark_existing_as_seen !== false,
|
|
34
|
+
});
|
|
35
|
+
return NextResponse.json({ job });
|
|
36
|
+
}
|
|
@@ -9,25 +9,57 @@ export async function POST() {
|
|
|
9
9
|
return NextResponse.json({ ok: false, error: 'Telegram bot token or chat ID not configured' });
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// telegramChatId may be a comma-separated whitelist. The test message
|
|
13
|
+
// goes to the FIRST id so a misconfigured later id doesn't fail the test.
|
|
14
|
+
const firstChatId = telegramChatId.split(',').map((s) => s.trim()).filter(Boolean)[0];
|
|
15
|
+
if (!firstChatId) {
|
|
16
|
+
return NextResponse.json({ ok: false, error: 'telegramChatId is empty / malformed' });
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
try {
|
|
13
20
|
const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
|
|
21
|
+
// Plain text — no parse_mode — so an em-dash / asterisk / underscore
|
|
22
|
+
// can never trip Telegram's Markdown parser ("can't parse entities").
|
|
14
23
|
const res = await fetch(url, {
|
|
15
24
|
method: 'POST',
|
|
16
25
|
headers: { 'Content-Type': 'application/json' },
|
|
17
26
|
body: JSON.stringify({
|
|
18
|
-
chat_id:
|
|
19
|
-
text: '✅
|
|
20
|
-
parse_mode: 'Markdown',
|
|
27
|
+
chat_id: firstChatId,
|
|
28
|
+
text: '✅ Forge — Test notification! Telegram notifications are working.',
|
|
21
29
|
}),
|
|
22
30
|
});
|
|
23
31
|
|
|
32
|
+
const body = await res.text();
|
|
24
33
|
if (!res.ok) {
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
// Surface Telegram's "description" — covers the common cases like
|
|
35
|
+
// "chat not found", "Unauthorized" (token revoked), "bot was blocked".
|
|
36
|
+
try {
|
|
37
|
+
const j = JSON.parse(body);
|
|
38
|
+
return NextResponse.json({ ok: false, error: `${res.status} ${j.description || body}` });
|
|
39
|
+
} catch {
|
|
40
|
+
return NextResponse.json({ ok: false, error: `${res.status} ${body}` });
|
|
41
|
+
}
|
|
27
42
|
}
|
|
28
|
-
|
|
29
43
|
return NextResponse.json({ ok: true });
|
|
30
44
|
} catch (err: any) {
|
|
31
|
-
|
|
45
|
+
// 'fetch failed' from Node is opaque — almost always corp SSL inspection
|
|
46
|
+
// (Zscaler / corporate firewall) or proxy / DNS / network. Surface
|
|
47
|
+
// concrete remediation in the alert so the user doesn't have to guess.
|
|
48
|
+
const msg = String(err?.message || err);
|
|
49
|
+
if (/fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|UNABLE_TO_VERIFY|self.signed/i.test(msg)) {
|
|
50
|
+
const cause = err?.cause?.code || err?.cause?.message || '';
|
|
51
|
+
return NextResponse.json({
|
|
52
|
+
ok: false,
|
|
53
|
+
error: [
|
|
54
|
+
`${msg}${cause ? ' (cause: ' + cause + ')' : ''}`,
|
|
55
|
+
'',
|
|
56
|
+
'Forge could not reach api.telegram.org. Common causes:',
|
|
57
|
+
' • Corporate TLS inspection — set NODE_EXTRA_CA_CERTS=/path/to/corp-ca.pem then `forge server restart`',
|
|
58
|
+
' • HTTP proxy required — set HTTPS_PROXY=http://proxy.corp:port then restart',
|
|
59
|
+
' • Network / DNS — curl -v https://api.telegram.org/bot<TOKEN>/getMe',
|
|
60
|
+
].join('\n'),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return NextResponse.json({ ok: false, error: msg });
|
|
32
64
|
}
|
|
33
65
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { getPipeline, cancelPipeline, deletePipeline, injectConversationMessage } from '@/lib/pipeline';
|
|
2
|
+
import { getPipeline, cancelPipeline, deletePipeline, injectConversationMessage, retryNode } from '@/lib/pipeline';
|
|
3
3
|
|
|
4
4
|
// GET /api/pipelines/:id
|
|
5
5
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
@@ -25,6 +25,15 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
25
25
|
return NextResponse.json({ ok });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Retry a single failed node — see lib/pipeline.ts retryNode for semantics.
|
|
29
|
+
if (action === 'retry-node') {
|
|
30
|
+
const { nodeId } = body;
|
|
31
|
+
if (!nodeId) return NextResponse.json({ error: 'nodeId required' }, { status: 400 });
|
|
32
|
+
const result = await retryNode(id, String(nodeId));
|
|
33
|
+
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
|
|
34
|
+
return NextResponse.json({ ok: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
// Inject a message into a running conversation
|
|
29
38
|
if (action === 'inject') {
|
|
30
39
|
const { agentId, message } = body;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { listPipelines, listWorkflows, startPipeline } from '@/lib/pipeline';
|
|
2
|
+
import { listPipelines, listPipelinesSummary, listWorkflows, startPipeline } from '@/lib/pipeline';
|
|
3
3
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import YAML from 'yaml';
|
|
@@ -40,7 +40,21 @@ export async function GET(req: Request) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// Light summary by default — strips heavy node outputs / conversation
|
|
44
|
+
// bodies that the list UI doesn't render. Default limit 100 newest.
|
|
45
|
+
// Callers can opt into:
|
|
46
|
+
// ?full=1 — full Pipeline objects (rare; per-id endpoint is cheaper)
|
|
47
|
+
// ?limit=500 — more rows (server caps at 500)
|
|
48
|
+
// ?workflow=<name>— scope to one workflow (combine with high limit)
|
|
49
|
+
// ?before=<iso> — cursor for "Load more"
|
|
50
|
+
const wantFull = searchParams.get('full') === '1';
|
|
51
|
+
if (wantFull) {
|
|
52
|
+
return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
|
|
53
|
+
}
|
|
54
|
+
const limit = Number(searchParams.get('limit')) || undefined;
|
|
55
|
+
const workflow = searchParams.get('workflow') || undefined;
|
|
56
|
+
const before = searchParams.get('before') || undefined;
|
|
57
|
+
return NextResponse.json(listPipelinesSummary({ limit, workflow, before }));
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
// POST /api/pipelines — start a pipeline or save a workflow
|
package/app/api/plugins/route.ts
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { listPlugins, getPlugin, installPlugin, uninstallPlugin, updatePluginConfig, listInstalledPlugins, getInstalledPlugin } from '@/lib/plugins/registry';
|
|
2
|
+
import { listPlugins, getPlugin, installPlugin, uninstallPlugin, updatePluginConfig, listInstalledPlugins, getInstalledPlugin, getSecretFieldNames } from '@/lib/plugins/registry';
|
|
3
3
|
import { executePluginAction } from '@/lib/plugins/executor';
|
|
4
4
|
|
|
5
|
+
const SECRET_MASK = '••••••••';
|
|
6
|
+
|
|
7
|
+
function maskInstalledConfig<T extends { definition?: any; config?: Record<string, any> } | null | undefined>(inst: T): T {
|
|
8
|
+
if (!inst || !inst.config) return inst;
|
|
9
|
+
const secrets = getSecretFieldNames(inst.definition || null);
|
|
10
|
+
if (!secrets.length) return inst;
|
|
11
|
+
const masked = { ...inst.config };
|
|
12
|
+
for (const k of secrets) {
|
|
13
|
+
if (typeof masked[k] === 'string' && masked[k]) masked[k] = SECRET_MASK;
|
|
14
|
+
}
|
|
15
|
+
return { ...inst, config: masked } as T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function restoreInstalledConfig(id: string, incoming: Record<string, any>): Record<string, any> {
|
|
19
|
+
const existing = getInstalledPlugin(id);
|
|
20
|
+
if (!existing) return incoming;
|
|
21
|
+
const secrets = getSecretFieldNames(existing.definition);
|
|
22
|
+
if (!secrets.length) return incoming;
|
|
23
|
+
const out = { ...incoming };
|
|
24
|
+
for (const k of secrets) {
|
|
25
|
+
if (out[k] === SECRET_MASK) {
|
|
26
|
+
const prev = existing.config?.[k];
|
|
27
|
+
if (typeof prev === 'string') out[k] = prev;
|
|
28
|
+
else delete out[k];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
5
34
|
// GET: list plugins or get plugin details
|
|
6
35
|
export async function GET(req: Request) {
|
|
7
36
|
const url = new URL(req.url);
|
|
@@ -13,17 +42,18 @@ export async function GET(req: Request) {
|
|
|
13
42
|
const plugin = getPlugin(id);
|
|
14
43
|
const inst = getInstalledPlugin(id);
|
|
15
44
|
if (!plugin && !inst) return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
|
45
|
+
const masked = maskInstalledConfig(inst);
|
|
16
46
|
return NextResponse.json({
|
|
17
|
-
plugin:
|
|
47
|
+
plugin: masked?.definition || plugin,
|
|
18
48
|
installed: !!inst,
|
|
19
|
-
config:
|
|
20
|
-
instanceName:
|
|
21
|
-
source:
|
|
49
|
+
config: masked?.config,
|
|
50
|
+
instanceName: masked?.instanceName,
|
|
51
|
+
source: masked?.source,
|
|
22
52
|
});
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
if (installed === 'true') {
|
|
26
|
-
return NextResponse.json({ plugins: listInstalledPlugins() });
|
|
56
|
+
return NextResponse.json({ plugins: listInstalledPlugins().map(p => maskInstalledConfig(p)) });
|
|
27
57
|
}
|
|
28
58
|
|
|
29
59
|
return NextResponse.json({ plugins: listPlugins() });
|
|
@@ -37,7 +67,8 @@ export async function POST(req: Request) {
|
|
|
37
67
|
switch (action) {
|
|
38
68
|
case 'install': {
|
|
39
69
|
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
|
|
40
|
-
const
|
|
70
|
+
const merged = restoreInstalledConfig(id, config || {});
|
|
71
|
+
const ok = installPlugin(id, merged, body.source ? { source: body.source, name: body.name } : undefined);
|
|
41
72
|
return NextResponse.json({ ok });
|
|
42
73
|
}
|
|
43
74
|
case 'create_instance': {
|
|
@@ -59,7 +90,8 @@ export async function POST(req: Request) {
|
|
|
59
90
|
}
|
|
60
91
|
case 'update_config': {
|
|
61
92
|
if (!id || !config) return NextResponse.json({ error: 'id and config required' }, { status: 400 });
|
|
62
|
-
const
|
|
93
|
+
const merged = restoreInstalledConfig(id, config);
|
|
94
|
+
const ok = updatePluginConfig(id, merged);
|
|
63
95
|
return NextResponse.json({ ok });
|
|
64
96
|
}
|
|
65
97
|
case 'test': {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { getFixedSession, setFixedSession, clearFixedSession, getAllFixedSessions } from '@/lib/project-sessions';
|
|
3
|
-
import {
|
|
3
|
+
import { loadSettings } from '@/lib/settings';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
|
|
6
7
|
// GET: get fixed session for a project, or all bindings
|
|
@@ -35,27 +36,66 @@ export async function POST(req: Request) {
|
|
|
35
36
|
return NextResponse.json({ ok: true, projectPath, fixedSessionId });
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Generate the project's MCP config. Two locations:
|
|
41
|
+
* 1. <project>/.forge/mcp.json — Forge-internal copy, referenced by workspace
|
|
42
|
+
* orchestrator's --mcp-config flag when it launches agent terminals.
|
|
43
|
+
* 2. <project>/.mcp.json — picked up automatically by `claude` (and
|
|
44
|
+
* most other Claude Code-compatible CLIs) when launched at the project
|
|
45
|
+
* root, so users get Forge MCP tools without needing --mcp-config.
|
|
46
|
+
* Both files have identical content; (1) is authoritative — (2) is a mirror
|
|
47
|
+
* that's only written if it doesn't already exist OR is also a Forge-managed
|
|
48
|
+
* copy (to avoid stomping a user's hand-rolled .mcp.json).
|
|
49
|
+
*/
|
|
39
50
|
function ensureMcpConfig(projectPath: string): void {
|
|
40
51
|
try {
|
|
41
52
|
const forgeDir = join(projectPath, '.forge');
|
|
42
|
-
const
|
|
53
|
+
const forgeConfigPath = join(forgeDir, 'mcp.json');
|
|
54
|
+
const rootConfigPath = join(projectPath, '.mcp.json');
|
|
43
55
|
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
44
56
|
|
|
45
|
-
// Resolve workspace + primary agent for this project
|
|
46
57
|
let wsParam = '';
|
|
47
58
|
try {
|
|
48
59
|
const { findWorkspaceByProject } = require('@/lib/workspace');
|
|
49
60
|
const ws = findWorkspaceByProject(projectPath);
|
|
50
|
-
if (ws) {
|
|
51
|
-
wsParam = `?workspaceId=${ws.id}`;
|
|
52
|
-
}
|
|
61
|
+
if (ws) wsParam = `?workspaceId=${ws.id}`;
|
|
53
62
|
} catch {}
|
|
54
63
|
|
|
55
|
-
|
|
64
|
+
// Built-in Forge MCP server (agent bus, workspace ops)
|
|
65
|
+
const forgeEntry = { type: 'sse', url: `http://localhost:${mcpPort}/sse${wsParam}` };
|
|
66
|
+
|
|
67
|
+
// User-managed external MCP servers — chrome-devtools-mcp etc.
|
|
68
|
+
let userServers: Record<string, any> = {};
|
|
69
|
+
try {
|
|
70
|
+
const settings = loadSettings();
|
|
71
|
+
userServers = settings.mcpServers || {};
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
// Forge owns the `forge` key; users can shadow other keys here.
|
|
75
|
+
const config = {
|
|
76
|
+
mcpServers: { forge: forgeEntry, ...userServers },
|
|
77
|
+
};
|
|
78
|
+
const serialized = JSON.stringify(config, null, 2);
|
|
79
|
+
const forgeManagedKeys = new Set(Object.keys(config.mcpServers));
|
|
56
80
|
|
|
57
|
-
// Always rewrite (workspace context may have changed)
|
|
58
81
|
mkdirSync(forgeDir, { recursive: true });
|
|
59
|
-
writeFileSync(
|
|
82
|
+
writeFileSync(forgeConfigPath, serialized);
|
|
83
|
+
|
|
84
|
+
// Mirror to root .mcp.json so plain `claude` in the project picks up
|
|
85
|
+
// Forge's MCP servers. Two policies:
|
|
86
|
+
// - Don't CREATE the root file. Users who delete it want it gone;
|
|
87
|
+
// auto-recreating broke that expectation (and pulled global
|
|
88
|
+
// mcp servers into pipeline worktrees where they don't belong).
|
|
89
|
+
// - DO update an existing root file IF every server in it is one
|
|
90
|
+
// we manage — keeps the convenience for users who chose to have it,
|
|
91
|
+
// without stomping a hand-rolled one.
|
|
92
|
+
if (existsSync(rootConfigPath)) {
|
|
93
|
+
try {
|
|
94
|
+
const existing = JSON.parse(readFileSync(rootConfigPath, 'utf8'));
|
|
95
|
+
const existingKeys = Object.keys(existing?.mcpServers ?? {});
|
|
96
|
+
const allForgeManaged = existingKeys.length > 0 && existingKeys.every(k => forgeManagedKeys.has(k));
|
|
97
|
+
if (allForgeManaged) writeFileSync(rootConfigPath, serialized);
|
|
98
|
+
} catch { /* malformed user file — leave alone */ }
|
|
99
|
+
}
|
|
60
100
|
} catch {}
|
|
61
101
|
}
|
|
@@ -53,6 +53,19 @@ export async function PUT(req: Request) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Nested agent profile apiKeys are masked by loadSettingsMasked too —
|
|
57
|
+
// restore them here so a normal settings save doesn't overwrite a real
|
|
58
|
+
// key with '••••••••'. Empty string means user explicitly cleared it.
|
|
59
|
+
if (updated.agents) {
|
|
60
|
+
for (const [id, a] of Object.entries(updated.agents)) {
|
|
61
|
+
const newKey = (a as any)?.apiKey;
|
|
62
|
+
if (newKey === '••••••••') {
|
|
63
|
+
const existing = settings.agents?.[id]?.apiKey;
|
|
64
|
+
if (existing) (a as any).apiKey = existing;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
// Remove internal fields
|
|
57
70
|
delete (updated as any)._secretStatus;
|
|
58
71
|
|