@aion0/forge 0.9.0 → 0.9.2
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 +60 -7
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +116 -7
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- package/lib/help-docs/22-recipes.md +0 -124
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
|
-
# Forge v0.9.
|
|
1
|
+
# Forge v0.9.2
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-26
|
|
4
4
|
|
|
5
|
-
## Changes since v0.
|
|
5
|
+
## Changes since v0.9.1
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
- fix(jobs/run): manual fire actually passes trigger='manual' to executeRun
|
|
9
|
+
- fix: nuke all remaining CommonJS require() in task / jobs hot paths
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
- feat(pipeline): {{run.tmp_dir}} per-run scratch + GC + Settings UI for API profiles
|
|
13
|
+
- feat(schedules): skills field + reconciler race fix + chat action SSE fanout
|
|
14
|
+
- docs: team workflow integration + side-panel UI design brief
|
|
6
15
|
|
|
7
16
|
### Other
|
|
8
|
-
-
|
|
9
|
-
- feat(
|
|
10
|
-
-
|
|
17
|
+
- chore(server): drop misleading 'Stop:' line from start output
|
|
18
|
+
- feat(pipeline): for_each loop primitive + before: setup-phase hook
|
|
19
|
+
- feat(pipeline): {{run.tmp_dir}} per-run scratch + GC + Settings UI for API profiles
|
|
20
|
+
- feat(schedules): skills field + reconciler race fix + chat action SSE fanout
|
|
21
|
+
- fix(server): kill zombie tsx task-runner processes from prior dev sessions
|
|
22
|
+
- feat(pipeline): {{raw:…}} template variant — bypass shell ANSI-C escape
|
|
23
|
+
- chore(sync): cache-bust workflow fetches + force flag for connector sync
|
|
24
|
+
- feat(schedules): connector_tool Test button + tool-test endpoint
|
|
25
|
+
- feat(schedules): edit mode + run details + delete-resurrection fix
|
|
26
|
+
- feat(pipeline): extended input field spec — type/enum/required/default/multiline
|
|
27
|
+
- fix(schedules): connector_tool picker + schema-driven Step 2 form
|
|
28
|
+
- feat(schedules v2): unified describeAction helper for cards
|
|
29
|
+
- feat(schedules v2): Phase 6 — action=telegram via existing bot
|
|
30
|
+
- feat(schedules v2): Phase 5 — action=email via SMTP
|
|
31
|
+
- feat(schedules v2): Phase 4 — action=chat appends body output to a session
|
|
32
|
+
- feat(schedules v2): Phase 3 — body_kind=connector_tool dispatch + UI picker
|
|
33
|
+
- feat(schedules v2): Phase 2 — body_kind=skill dispatch + UI picker
|
|
34
|
+
- feat(schedules v2): Phase 1 — schema rename + body/action field extension
|
|
35
|
+
- fix(pipeline): reconciler honors retries; task-manager catches log err.stack
|
|
36
|
+
- fix(schedules): rename pipeline schema route from [name] to [id]
|
|
37
|
+
- docs(schedules): help-docs/13-schedules.md for Help AI + users
|
|
38
|
+
- feat(schedules UI): Forge web SchedulesView + 3-step Create modal
|
|
39
|
+
- feat(schedules): backend — pipeline_schedules + scheduler + 8 API + pipeline schema
|
|
40
|
+
- fix(claude-process): kill require() on Claude task spawn path
|
|
41
|
+
- fix(jobs/run): manual fire actually passes trigger='manual' to executeRun
|
|
42
|
+
- feat(bulk-delete): cleanup old tasks + pipeline runs by age
|
|
43
|
+
- fix(jobs UI): refresh + poll while expanded — Cancel/Stop buttons appear after Run
|
|
44
|
+
- feat(jobs UI): Dispatched pipelines list trims to running + last-terminal
|
|
45
|
+
- fix(jobs): on_failure=stop skips manual fires — let user resume after failure
|
|
46
|
+
- fix(pipeline): cascading cancel — task → node → pipeline → job, all in sync
|
|
47
|
+
- feat(pipeline): per-node retry — retries: N + retry_delay_ms
|
|
48
|
+
- fix(dirs): break dirs↔settings cycle without require() — kills last hot-path race
|
|
49
|
+
- feat(jobs): on_failure policy — 'continue' (default) | 'stop'
|
|
50
|
+
- feat(jobs/UI): aggregate status summary + deferred-queue hint
|
|
51
|
+
- feat(jobs): monitor + cancel — Job tracks its dispatched pipelines, can stop drain or kill in-flight
|
|
52
|
+
- fix(jobs): sequential mode drains deferred items across ticks regardless of schedule_kind
|
|
53
|
+
- feat(connectors/gitlab): Sync to glab CLI button — unify token between Forge and shell
|
|
54
|
+
- feat(jobs): isJobBusy gate — refuse double-fire + disable UI buttons
|
|
55
|
+
- feat(jobs UI): show + toggle concurrency_mode in JobsView
|
|
56
|
+
- feat(jobs): per-Job concurrency_mode — sequential by default
|
|
57
|
+
- fix(notify): use globalThis Symbol for pipelineTaskIds, drop require()
|
|
58
|
+
- fix(jobs/scheduler): reconcile zombie pipeline_runs before counting cap
|
|
59
|
+
- fix(pipeline): empty try-catch audit — add console.warn where silence hid bugs
|
|
60
|
+
- fix(pipeline): remove all CommonJS require() — package is type:module
|
|
61
|
+
- refactor(pipeline): move 3 legacy builtins to marketplace
|
|
62
|
+
- fix(pipeline): listWorkflows logs WHY a yaml was skipped
|
|
63
|
+
- feat(jobs): pickDedupKey supports composite key (field1:field2)
|
|
11
64
|
|
|
12
65
|
|
|
13
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.
|
|
66
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.1...v0.9.2
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/agents/[id]/test
|
|
3
|
+
*
|
|
4
|
+
* Reachability probe for an API profile. Resolves baseUrl + apiKey from
|
|
5
|
+
* the saved profile (settings.agents[id]), then pings the provider's
|
|
6
|
+
* /models endpoint:
|
|
7
|
+
*
|
|
8
|
+
* - anthropic: GET {baseUrl}/v1/models x-api-key, anthropic-version
|
|
9
|
+
* - openai-shape: GET {baseUrl}/models Authorization: Bearer
|
|
10
|
+
*
|
|
11
|
+
* Body (optional, all fields override saved values — lets the user test
|
|
12
|
+
* before saving):
|
|
13
|
+
* { apiKey?, baseUrl?, provider?, model? }
|
|
14
|
+
*
|
|
15
|
+
* Result shape mirrors /api/connectors/[id]/test:
|
|
16
|
+
* { ok, status, message?, error?, duration_ms, body_preview? }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { NextResponse } from 'next/server';
|
|
20
|
+
import { loadSettings } from '@/lib/settings';
|
|
21
|
+
import { inferAdapter, pickApiKey, pickBaseUrl, defaultBaseUrl } from '@/lib/chat/agent-loop';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
24
|
+
const MAX_BODY_PREVIEW = 512;
|
|
25
|
+
|
|
26
|
+
interface TestResult {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
status?: number;
|
|
29
|
+
message?: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
duration_ms?: number;
|
|
32
|
+
body_preview?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
36
|
+
const { id } = await params;
|
|
37
|
+
const settings = loadSettings();
|
|
38
|
+
const saved = (settings.agents || {})[id] as any | undefined;
|
|
39
|
+
|
|
40
|
+
let override: any = {};
|
|
41
|
+
try { override = (await req.json()) ?? {}; } catch { override = {}; }
|
|
42
|
+
|
|
43
|
+
// Settings GET masks secrets as '••••••••' before sending to the client;
|
|
44
|
+
// if the user hasn't re-typed the API key, the form re-submits the mask.
|
|
45
|
+
// Treat the sentinel as "use the saved value".
|
|
46
|
+
const MASK = '••••••••';
|
|
47
|
+
const unmask = (v: any, fallback: any) => (typeof v === 'string' && v === MASK ? fallback : v);
|
|
48
|
+
|
|
49
|
+
// Merge — body overrides saved, but unsaved fields ok if body has them.
|
|
50
|
+
const profile = {
|
|
51
|
+
type: 'api' as const,
|
|
52
|
+
provider: override.provider ?? saved?.provider,
|
|
53
|
+
apiKey: unmask(override.apiKey, saved?.apiKey) ?? saved?.apiKey,
|
|
54
|
+
baseUrl: override.baseUrl ?? saved?.baseUrl,
|
|
55
|
+
model: override.model ?? saved?.model,
|
|
56
|
+
env: saved?.env,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (!profile.provider) {
|
|
60
|
+
return NextResponse.json<TestResult>({ ok: false, error: 'profile has no provider' });
|
|
61
|
+
}
|
|
62
|
+
const adapter = inferAdapter(profile.provider);
|
|
63
|
+
const apiKey = pickApiKey(profile, adapter);
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
return NextResponse.json<TestResult>({ ok: false, error: 'apiKey is empty (and no fallback in env.*_API_KEY)' });
|
|
66
|
+
}
|
|
67
|
+
const baseUrl = pickBaseUrl(profile, adapter) || defaultBaseUrl(profile.provider);
|
|
68
|
+
if (!baseUrl) {
|
|
69
|
+
return NextResponse.json<TestResult>({ ok: false, error: `no baseUrl resolved for provider '${profile.provider}'` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const url = adapter === 'anthropic'
|
|
73
|
+
? `${baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '')}/v1/models`
|
|
74
|
+
: `${baseUrl.replace(/\/+$/, '')}/models`;
|
|
75
|
+
const headers: Record<string, string> = {};
|
|
76
|
+
if (adapter === 'anthropic') {
|
|
77
|
+
// Claude Code OAuth access tokens (sk-ant-oat*) use Bearer auth +
|
|
78
|
+
// the oauth beta header; standard API keys (sk-ant-api*) use x-api-key.
|
|
79
|
+
if (apiKey.startsWith('sk-ant-oat')) {
|
|
80
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
81
|
+
headers['anthropic-beta'] = 'oauth-2025-04-20';
|
|
82
|
+
} else {
|
|
83
|
+
headers['x-api-key'] = apiKey;
|
|
84
|
+
}
|
|
85
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
86
|
+
} else {
|
|
87
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ctrl = new AbortController();
|
|
91
|
+
const timer = setTimeout(() => ctrl.abort(), DEFAULT_TIMEOUT_MS);
|
|
92
|
+
const t0 = Date.now();
|
|
93
|
+
let res: Response;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(url, { method: 'GET', headers, signal: ctrl.signal });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
const err = e as Error & { cause?: any };
|
|
99
|
+
const causeMsg = err.cause?.message || err.cause?.code || '';
|
|
100
|
+
return NextResponse.json<TestResult>({
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `request failed: ${err.message}${causeMsg ? ` (${causeMsg})` : ''} → ${url}`,
|
|
103
|
+
duration_ms: Date.now() - t0,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
const duration = Date.now() - t0;
|
|
108
|
+
const text = await res.text().catch(() => '');
|
|
109
|
+
const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
|
|
110
|
+
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
let errMsg = `HTTP ${res.status} ${res.statusText}`;
|
|
113
|
+
try {
|
|
114
|
+
const j = JSON.parse(text);
|
|
115
|
+
const detail = j?.error?.message || j?.error || j?.message;
|
|
116
|
+
if (typeof detail === 'string') errMsg += `: ${detail}`;
|
|
117
|
+
} catch {}
|
|
118
|
+
return NextResponse.json<TestResult>({
|
|
119
|
+
ok: false,
|
|
120
|
+
status: res.status,
|
|
121
|
+
error: errMsg,
|
|
122
|
+
duration_ms: duration,
|
|
123
|
+
body_preview: preview,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Parse model count + optionally verify cfg.model is in the list.
|
|
128
|
+
let modelCount = 0;
|
|
129
|
+
let modelFound: boolean | null = null;
|
|
130
|
+
try {
|
|
131
|
+
const j = JSON.parse(text);
|
|
132
|
+
const arr = Array.isArray(j?.data) ? j.data : Array.isArray(j?.models) ? j.models : [];
|
|
133
|
+
modelCount = arr.length;
|
|
134
|
+
if (profile.model && arr.length > 0) {
|
|
135
|
+
modelFound = arr.some((m: any) => (m?.id || m?.name) === profile.model);
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
|
|
139
|
+
const parts = [`HTTP ${res.status}`, `${modelCount} model${modelCount === 1 ? '' : 's'}`];
|
|
140
|
+
if (profile.model) {
|
|
141
|
+
parts.push(modelFound === false ? `'${profile.model}' NOT in list` : `'${profile.model}' OK`);
|
|
142
|
+
}
|
|
143
|
+
return NextResponse.json<TestResult>({
|
|
144
|
+
ok: true,
|
|
145
|
+
status: res.status,
|
|
146
|
+
message: parts.join(' · '),
|
|
147
|
+
duration_ms: duration,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/connectors/<id>/sync-cli
|
|
3
|
+
*
|
|
4
|
+
* For connectors whose token doubles as a CLI tool's auth (currently
|
|
5
|
+
* gitlab → `glab auth login`), write the Forge-stored token into the
|
|
6
|
+
* CLI's own config so `glab` invocations from the user's terminal
|
|
7
|
+
* use the same token Forge does for pipelines.
|
|
8
|
+
*
|
|
9
|
+
* Without this users hit "Forge says my MR pipeline 401s but I can run
|
|
10
|
+
* glab fine in shell" — because shell glab has a stale revoked token
|
|
11
|
+
* in ~/.config/glab-cli/config.yml while Forge has the fresh PAT.
|
|
12
|
+
*
|
|
13
|
+
* Currently only `gitlab` is supported. Other CLIs (gh) could be added
|
|
14
|
+
* by extending the switch below.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { NextResponse } from 'next/server';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { getInstalledConnector } from '@/lib/connectors/registry';
|
|
20
|
+
|
|
21
|
+
function runGlabLogin(host: string, token: string): Promise<{ ok: boolean; stderr: string }> {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
// Use --stdin so the token never appears in argv (visible in ps).
|
|
24
|
+
const child = spawn('glab', ['auth', 'login', '--hostname', host, '--stdin'], {
|
|
25
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
26
|
+
});
|
|
27
|
+
let stderr = '';
|
|
28
|
+
child.stderr.on('data', (b: Buffer) => { stderr += b.toString(); });
|
|
29
|
+
child.stdout.on('data', () => { /* swallow */ });
|
|
30
|
+
child.on('error', (e) => resolve({ ok: false, stderr: `spawn failed: ${e.message}` }));
|
|
31
|
+
child.on('exit', (code) => resolve({ ok: code === 0, stderr }));
|
|
32
|
+
child.stdin.write(token + '\n');
|
|
33
|
+
child.stdin.end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
38
|
+
const { id } = await params;
|
|
39
|
+
|
|
40
|
+
if (id !== 'gitlab') {
|
|
41
|
+
return NextResponse.json({ error: `sync-cli is only implemented for gitlab (got: ${id})` }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const conn = getInstalledConnector('gitlab');
|
|
45
|
+
if (!conn || !conn.enabled) {
|
|
46
|
+
return NextResponse.json({ error: 'gitlab connector is not installed or not enabled' }, { status: 404 });
|
|
47
|
+
}
|
|
48
|
+
const token = typeof conn.config?.token === 'string' ? conn.config.token.trim() : '';
|
|
49
|
+
const baseUrl = typeof conn.config?.base_url === 'string' ? conn.config.base_url.trim() : '';
|
|
50
|
+
if (!token) {
|
|
51
|
+
return NextResponse.json({ error: 'gitlab connector has no token set — fill it in first, save, then sync' }, { status: 400 });
|
|
52
|
+
}
|
|
53
|
+
if (!baseUrl) {
|
|
54
|
+
return NextResponse.json({ error: 'gitlab connector has no base_url set' }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
let host: string;
|
|
57
|
+
try {
|
|
58
|
+
host = new URL(baseUrl).host;
|
|
59
|
+
} catch {
|
|
60
|
+
return NextResponse.json({ error: `base_url "${baseUrl}" is not a valid URL` }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const r = await runGlabLogin(host, token);
|
|
64
|
+
if (!r.ok) {
|
|
65
|
+
return NextResponse.json({
|
|
66
|
+
ok: false,
|
|
67
|
+
host,
|
|
68
|
+
error: `glab auth login failed: ${r.stderr.trim() || '(no stderr)'}`,
|
|
69
|
+
hint: 'Is `glab` installed and on PATH? Try `which glab` in a terminal.',
|
|
70
|
+
}, { status: 500 });
|
|
71
|
+
}
|
|
72
|
+
return NextResponse.json({ ok: true, host, message: `glab auth login successful for ${host}` });
|
|
73
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/connectors/tool-test
|
|
3
|
+
*
|
|
4
|
+
* Dry-run a connector tool with user-supplied input and return the raw
|
|
5
|
+
* result. Used by the Schedule create modal's connector_tool form so
|
|
6
|
+
* users can validate parameters before saving the schedule.
|
|
7
|
+
*
|
|
8
|
+
* No dedup, no items_path massaging, no Job preview semantics — that's
|
|
9
|
+
* /api/jobs/preview. This endpoint is the minimal "did the tool call
|
|
10
|
+
* work?" probe.
|
|
11
|
+
*
|
|
12
|
+
* Request body:
|
|
13
|
+
* {
|
|
14
|
+
* plugin_id: string, // e.g. "gitlab"
|
|
15
|
+
* tool: string, // e.g. "me"
|
|
16
|
+
* input?: object // tool input args
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Response:
|
|
20
|
+
* {
|
|
21
|
+
* ok: boolean,
|
|
22
|
+
* is_error: boolean,
|
|
23
|
+
* content: string, // raw tool output (truncated to 8KB)
|
|
24
|
+
* duration_ms: number,
|
|
25
|
+
* content_full_size: number,
|
|
26
|
+
* error?: string // only when dispatch itself threw
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { NextResponse } from 'next/server';
|
|
31
|
+
import { dispatchTool } from '@/lib/chat/tool-dispatcher';
|
|
32
|
+
|
|
33
|
+
const MAX_PREVIEW = 8192;
|
|
34
|
+
|
|
35
|
+
export async function POST(req: Request) {
|
|
36
|
+
let body: any;
|
|
37
|
+
try { body = await req.json(); }
|
|
38
|
+
catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
|
|
39
|
+
|
|
40
|
+
const { plugin_id, tool, input } = body || {};
|
|
41
|
+
if (!plugin_id || !tool) {
|
|
42
|
+
return NextResponse.json({ ok: false, error: 'plugin_id and tool are required' }, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const callName = `${plugin_id}.${tool}`;
|
|
46
|
+
const t0 = Date.now();
|
|
47
|
+
try {
|
|
48
|
+
const result = await dispatchTool(
|
|
49
|
+
{ id: `tool-test-${Date.now()}`, name: callName, input: input || {} },
|
|
50
|
+
{ noTruncation: true },
|
|
51
|
+
);
|
|
52
|
+
const content = typeof result.content === 'string' ? result.content : JSON.stringify(result.content ?? '');
|
|
53
|
+
return NextResponse.json({
|
|
54
|
+
ok: !result.is_error,
|
|
55
|
+
is_error: !!result.is_error,
|
|
56
|
+
content: content.length > MAX_PREVIEW ? content.slice(0, MAX_PREVIEW) + '…' : content,
|
|
57
|
+
content_full_size: content.length,
|
|
58
|
+
duration_ms: Date.now() - t0,
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return NextResponse.json({
|
|
62
|
+
ok: false,
|
|
63
|
+
is_error: true,
|
|
64
|
+
content: '',
|
|
65
|
+
content_full_size: 0,
|
|
66
|
+
duration_ms: Date.now() - t0,
|
|
67
|
+
error: (e as Error)?.message || String(e),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/jobs/<id>/cancel
|
|
3
|
+
*
|
|
4
|
+
* Stop the sequential drain for this Job. Clears `next_run_at` so the
|
|
5
|
+
* scheduler won't pick the Job up again automatically. Pipelines that
|
|
6
|
+
* are already running are NOT touched — the user can cancel those
|
|
7
|
+
* individually on the Pipeline view if they want.
|
|
8
|
+
*
|
|
9
|
+
* Query / body:
|
|
10
|
+
* cancel_inflight=1 (optional) — also cancel any pipeline this Job
|
|
11
|
+
* dispatched that's still running. Best-effort.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NextResponse } from 'next/server';
|
|
15
|
+
import { getJob, cancelJobDrain, listJobDispatchedPipelines } from '@/lib/jobs/store';
|
|
16
|
+
import { cancelPipeline } from '@/lib/pipeline';
|
|
17
|
+
|
|
18
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
19
|
+
const { id } = await params;
|
|
20
|
+
const job = getJob(id);
|
|
21
|
+
if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
|
|
22
|
+
|
|
23
|
+
const url = new URL(req.url);
|
|
24
|
+
const cancelInflight = url.searchParams.get('cancel_inflight') === '1';
|
|
25
|
+
|
|
26
|
+
const stopped = cancelJobDrain(id);
|
|
27
|
+
|
|
28
|
+
let cancelledPipelines = 0;
|
|
29
|
+
if (cancelInflight) {
|
|
30
|
+
// Best-effort: walk the most-recent dispatched pipelines and
|
|
31
|
+
// cancel ones still running. cancelPipeline is a no-op for
|
|
32
|
+
// already-terminal pipelines, so this stays idempotent.
|
|
33
|
+
for (const p of listJobDispatchedPipelines(id, 50)) {
|
|
34
|
+
if (p.pipeline_status === 'running' || p.pipeline_status === 'pending') {
|
|
35
|
+
try {
|
|
36
|
+
if (cancelPipeline(p.pipeline_id)) cancelledPipelines++;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.warn(`[jobs/cancel] cancelPipeline(${p.pipeline_id}) failed: ${(e as Error).message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return NextResponse.json({
|
|
45
|
+
ok: true,
|
|
46
|
+
drain_stopped: stopped,
|
|
47
|
+
cancelled_pipelines: cancelledPipelines,
|
|
48
|
+
cancel_inflight_requested: cancelInflight,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/jobs/<id>/dispatched-pipelines?limit=20
|
|
3
|
+
*
|
|
4
|
+
* Recent pipelines this Job has dispatched, decorated with live
|
|
5
|
+
* pipeline_runs status. Lets the Jobs UI show "what did this Job
|
|
6
|
+
* fire and what state are they in" without a roundtrip per row.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { NextResponse } from 'next/server';
|
|
10
|
+
import { listJobDispatchedPipelines, getJob } from '@/lib/jobs/store';
|
|
11
|
+
|
|
12
|
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
const job = getJob(id);
|
|
15
|
+
if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
|
|
16
|
+
|
|
17
|
+
const url = new URL(req.url);
|
|
18
|
+
const limitParam = url.searchParams.get('limit');
|
|
19
|
+
const limit = limitParam && Number.isFinite(Number(limitParam))
|
|
20
|
+
? Math.min(Math.max(Number(limitParam), 1), 100)
|
|
21
|
+
: 20;
|
|
22
|
+
|
|
23
|
+
return NextResponse.json({ pipelines: listJobDispatchedPipelines(id, limit) });
|
|
24
|
+
}
|
|
@@ -12,13 +12,29 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { NextResponse } from 'next/server';
|
|
15
|
-
import { prepareRun, executeRun } from '@/lib/jobs/scheduler';
|
|
15
|
+
import { prepareRun, executeRun, isJobBusy } from '@/lib/jobs/scheduler';
|
|
16
16
|
import { resetDedup } from '@/lib/jobs/store';
|
|
17
17
|
|
|
18
18
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
19
19
|
const { id } = await params;
|
|
20
20
|
const url = new URL(req.url);
|
|
21
21
|
const shouldReset = url.searchParams.get('reset_dedup') === '1';
|
|
22
|
+
|
|
23
|
+
// Refuse to fire if the Job is mid-tick or, for sequential mode,
|
|
24
|
+
// has a previously-dispatched pipeline still running. Without this
|
|
25
|
+
// double-clicking Force run was queuing identical concurrent ticks
|
|
26
|
+
// that all dispatched the same items in parallel — defeating the
|
|
27
|
+
// sequential safety. The check runs reconcile internally so stale
|
|
28
|
+
// zombies don't block legitimate manual fires.
|
|
29
|
+
const busy = isJobBusy(id);
|
|
30
|
+
if (busy.busy) {
|
|
31
|
+
return NextResponse.json({
|
|
32
|
+
error: `Job is busy: ${busy.reason}. Wait for it to finish before firing again.`,
|
|
33
|
+
busy: true,
|
|
34
|
+
reason: busy.reason,
|
|
35
|
+
}, { status: 409 });
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
let removedDedupKeys = 0;
|
|
23
39
|
if (shouldReset) {
|
|
24
40
|
try { removedDedupKeys = resetDedup(id); }
|
|
@@ -32,7 +48,11 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|
|
32
48
|
} catch (e) {
|
|
33
49
|
return NextResponse.json({ error: (e as Error).message }, { status: 404 });
|
|
34
50
|
}
|
|
35
|
-
|
|
51
|
+
// Pass trigger='manual' so executeRun's on_failure=stop gate
|
|
52
|
+
// recognises this as a user-initiated resume and lets it through.
|
|
53
|
+
// Without this the run is treated as scheduled and halts on the
|
|
54
|
+
// last failure forever.
|
|
55
|
+
void executeRun(prepared.job, prepared.runId, 'manual').catch((err) => {
|
|
36
56
|
console.error('[jobs] manual run crashed', err);
|
|
37
57
|
});
|
|
38
58
|
return NextResponse.json({
|
package/app/api/jobs/route.ts
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
|
|
6
6
|
import { NextResponse } from 'next/server';
|
|
7
7
|
import { listJobs, createJob } from '@/lib/jobs/store';
|
|
8
|
+
import { isJobBusy } from '@/lib/jobs/scheduler';
|
|
8
9
|
|
|
9
10
|
export async function GET() {
|
|
10
|
-
|
|
11
|
+
// Decorate each job with a transient `busy` field so the UI can
|
|
12
|
+
// disable Run / Force-run buttons without doing a separate round
|
|
13
|
+
// trip per row. busy semantics live in scheduler.isJobBusy.
|
|
14
|
+
const jobs = listJobs().map((j) => {
|
|
15
|
+
const b = isJobBusy(j.id);
|
|
16
|
+
return { ...j, busy: b.busy, busy_reason: b.busy ? b.reason : undefined };
|
|
17
|
+
});
|
|
18
|
+
return NextResponse.json({ jobs });
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
export async function POST(req: Request) {
|
|
@@ -35,6 +43,8 @@ export async function POST(req: Request) {
|
|
|
35
43
|
schedule_at: body.schedule_at ? String(body.schedule_at) : null,
|
|
36
44
|
schedule_cron: body.schedule_cron ? String(body.schedule_cron) : null,
|
|
37
45
|
max_per_tick: Number.isFinite(Number(body.max_per_tick)) ? Number(body.max_per_tick) : undefined,
|
|
46
|
+
concurrency_mode: body.concurrency_mode === 'parallel' ? 'parallel' : 'sequential',
|
|
47
|
+
on_failure: body.on_failure === 'stop' ? 'stop' : 'continue',
|
|
38
48
|
mark_existing_as_seen: body.mark_existing_as_seen !== false,
|
|
39
49
|
});
|
|
40
50
|
return NextResponse.json({ job });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/pipelines/:id/schema
|
|
3
|
+
*
|
|
4
|
+
* Pipeline introspection for UI form rendering. Returns the
|
|
5
|
+
* declared input fields of a workflow.
|
|
6
|
+
*
|
|
7
|
+
* `:id` here actually carries the workflow NAME (not a pipeline_run id);
|
|
8
|
+
* the path segment must match the sibling `/api/pipelines/[id]/route.ts`
|
|
9
|
+
* to satisfy Next.js routing (one slug name per path depth).
|
|
10
|
+
*
|
|
11
|
+
* Input source supports BOTH shapes (pipeline.yaml `input:` block):
|
|
12
|
+
* bug_id: "Bug id (numeric)" ← legacy: description only
|
|
13
|
+
* bug_id: ← extended: full spec
|
|
14
|
+
* description: "Bug id"
|
|
15
|
+
* type: integer # string | integer | number | boolean | enum
|
|
16
|
+
* enum: [open, fixed] # only for type: enum
|
|
17
|
+
* required: true
|
|
18
|
+
* default: 0
|
|
19
|
+
* multiline: false # forces textarea
|
|
20
|
+
* label: "Bug ID" # display label
|
|
21
|
+
*
|
|
22
|
+
* Both forms are normalized to the same flat response so the UI
|
|
23
|
+
* doesn't need a per-shape branch.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { NextResponse } from 'next/server';
|
|
27
|
+
import { listWorkflows, normalizeInputField } from '@/lib/pipeline';
|
|
28
|
+
|
|
29
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
30
|
+
const { id } = await params;
|
|
31
|
+
const name = decodeURIComponent(id);
|
|
32
|
+
const workflow = listWorkflows().find((w) => w.name === name);
|
|
33
|
+
if (!workflow) {
|
|
34
|
+
return NextResponse.json({ error: `pipeline "${name}" not found` }, { status: 404 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const entries = Object.entries(workflow.input || {});
|
|
38
|
+
const fields = entries.map(([key, spec]) => normalizeInputField(key, spec));
|
|
39
|
+
|
|
40
|
+
// schema_version=2 when EVERY input uses the extended object form
|
|
41
|
+
// (or the pipeline declares no inputs at all — nothing to render).
|
|
42
|
+
// Any legacy string-form input → version 1. Lets clients (e.g. the
|
|
43
|
+
// browser extension) gate strict schema-driven rendering on v2 only
|
|
44
|
+
// and fall back to a "use Forge web" hint for v1.
|
|
45
|
+
const schemaVersion = entries.every(([, spec]) => typeof spec === 'object' && spec !== null) ? 2 : 1;
|
|
46
|
+
|
|
47
|
+
return NextResponse.json({
|
|
48
|
+
name: workflow.name,
|
|
49
|
+
description: workflow.description ?? null,
|
|
50
|
+
schema_version: schemaVersion,
|
|
51
|
+
input: fields,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/pipelines/bulk-delete
|
|
3
|
+
*
|
|
4
|
+
* { older_than_days?: number, before?: ISO, statuses?: string[] }
|
|
5
|
+
*
|
|
6
|
+
* Same shape as /api/tasks/bulk-delete. Skips running/pending pipelines
|
|
7
|
+
* — only terminal ones (done/failed/cancelled) are removed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NextResponse } from 'next/server';
|
|
11
|
+
import { bulkDeletePipelines } from '@/lib/pipeline';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: Request) {
|
|
14
|
+
let body: any = {};
|
|
15
|
+
try { body = await req.json(); } catch {}
|
|
16
|
+
|
|
17
|
+
let before: string;
|
|
18
|
+
if (typeof body.before === 'string' && body.before) {
|
|
19
|
+
before = body.before;
|
|
20
|
+
} else if (Number.isFinite(Number(body.older_than_days))) {
|
|
21
|
+
const days = Math.max(0, Number(body.older_than_days));
|
|
22
|
+
before = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
23
|
+
} else {
|
|
24
|
+
return NextResponse.json({ error: 'pass `older_than_days` (number) or `before` (ISO timestamp)' }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type PipelineStatus = 'done' | 'failed' | 'cancelled';
|
|
28
|
+
let statuses: PipelineStatus[] | undefined;
|
|
29
|
+
if (Array.isArray(body.statuses) && body.statuses.length) {
|
|
30
|
+
const valid: PipelineStatus[] = ['done', 'failed', 'cancelled'];
|
|
31
|
+
statuses = (body.statuses as string[]).filter((s) => valid.includes(s as PipelineStatus)) as PipelineStatus[];
|
|
32
|
+
if (statuses.length === 0) {
|
|
33
|
+
return NextResponse.json({ error: 'statuses must include done/failed/cancelled (running/pending cannot be bulk-deleted)' }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const removed = bulkDeletePipelines({ before, statuses });
|
|
38
|
+
return NextResponse.json({ removed, before, statuses: statuses ?? ['done', 'failed', 'cancelled'] });
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/pipelines/gc
|
|
3
|
+
*
|
|
4
|
+
* On-demand pipeline scratch-dir cleanup. Same logic as the background
|
|
5
|
+
* sweep in lib/init.ts; this endpoint exists so the user (or CI) can
|
|
6
|
+
* trigger it on demand via `forge pipeline gc`.
|
|
7
|
+
*
|
|
8
|
+
* Body (optional): { dry_run?: boolean }
|
|
9
|
+
*
|
|
10
|
+
* Response: { scanned, removed: [{path, reason}], kept: [{path, reason}] }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextResponse } from 'next/server';
|
|
14
|
+
import { gcPipelineTmp } from '@/lib/pipeline-gc';
|
|
15
|
+
|
|
16
|
+
export async function POST(req: Request) {
|
|
17
|
+
let body: any = {};
|
|
18
|
+
try { body = await req.json(); } catch {}
|
|
19
|
+
const dryRun = body?.dry_run === true || body?.dryRun === true;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const result = gcPipelineTmp({ dryRun });
|
|
23
|
+
return NextResponse.json({ ok: true, dryRun, ...result });
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/schedules/:id/cancel
|
|
3
|
+
*
|
|
4
|
+
* Cancel all inflight pipelines this schedule launched.
|
|
5
|
+
* Does NOT change enabled (use /stop for that).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NextResponse } from 'next/server';
|
|
9
|
+
import { getSchedule, listInflightForSchedule } from '@/lib/schedules/store';
|
|
10
|
+
import { cancelPipeline } from '@/lib/pipeline';
|
|
11
|
+
|
|
12
|
+
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
const s = getSchedule(id);
|
|
15
|
+
if (!s) return NextResponse.json({ error: 'schedule not found' }, { status: 404 });
|
|
16
|
+
|
|
17
|
+
let cancelled = 0;
|
|
18
|
+
for (const run of listInflightForSchedule(id)) {
|
|
19
|
+
// Phase 1: target_id is always a pipeline_id.
|
|
20
|
+
try { if (cancelPipeline(run.target_id)) cancelled++; }
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.warn(`[schedules/cancel] cancelPipeline(${run.target_id}) failed: ${(e as Error).message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return NextResponse.json({ cancelled });
|
|
27
|
+
}
|