@aion0/forge 0.9.1 → 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.
Files changed (70) hide show
  1. package/RELEASE_NOTES.md +60 -5
  2. package/app/api/agents/[id]/test/route.ts +150 -0
  3. package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
  4. package/app/api/connectors/tool-test/route.ts +70 -0
  5. package/app/api/jobs/[id]/cancel/route.ts +50 -0
  6. package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
  7. package/app/api/jobs/[id]/run/route.ts +22 -2
  8. package/app/api/jobs/route.ts +11 -1
  9. package/app/api/pipelines/[id]/schema/route.ts +53 -0
  10. package/app/api/pipelines/bulk-delete/route.ts +39 -0
  11. package/app/api/pipelines/gc/route.ts +27 -0
  12. package/app/api/schedules/[id]/cancel/route.ts +27 -0
  13. package/app/api/schedules/[id]/route.ts +173 -0
  14. package/app/api/schedules/[id]/run/route.ts +45 -0
  15. package/app/api/schedules/[id]/runs/route.ts +22 -0
  16. package/app/api/schedules/[id]/stop/route.ts +33 -0
  17. package/app/api/schedules/route.ts +175 -0
  18. package/app/api/tasks/bulk-delete/route.ts +47 -0
  19. package/bin/forge-server.mjs +22 -1
  20. package/cli/mw.mjs +186 -7657
  21. package/cli/mw.ts +26 -0
  22. package/components/ConnectorsPanel.tsx +46 -0
  23. package/components/Dashboard.tsx +23 -10
  24. package/components/JobsView.tsx +245 -6
  25. package/components/PipelineEditor.tsx +38 -1
  26. package/components/PipelineView.tsx +325 -4
  27. package/components/ScheduleCreateModal.tsx +1507 -0
  28. package/components/SchedulesView.tsx +605 -0
  29. package/components/SettingsModal.tsx +106 -0
  30. package/docs/Team-Workflow-Integration.md +487 -0
  31. package/docs/UI-Design-Brief-SidePanel.md +278 -0
  32. package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
  33. package/lib/__tests__/foreach-before.test.ts +201 -0
  34. package/lib/__tests__/foreach-parse.test.ts +114 -0
  35. package/lib/__tests__/foreach-snapshot.test.ts +112 -0
  36. package/lib/__tests__/foreach-source.test.ts +105 -0
  37. package/lib/__tests__/foreach-template.test.ts +112 -0
  38. package/lib/chat/agent-loop.ts +3 -3
  39. package/lib/chat-standalone.ts +26 -1
  40. package/lib/claude-process.ts +8 -5
  41. package/lib/connectors/sync.ts +8 -2
  42. package/lib/crypto.ts +1 -1
  43. package/lib/dirs.ts +22 -7
  44. package/lib/help-docs/05-pipelines.md +171 -0
  45. package/lib/help-docs/13-schedules.md +165 -0
  46. package/lib/help-docs/23-automation-states.md +148 -0
  47. package/lib/help-docs/CLAUDE.md +6 -6
  48. package/lib/init.ts +25 -6
  49. package/lib/jobs/recipes.ts +3 -2
  50. package/lib/jobs/scheduler.ts +215 -11
  51. package/lib/jobs/store.ts +79 -3
  52. package/lib/jobs/types.ts +31 -0
  53. package/lib/logger.ts +1 -1
  54. package/lib/notify.ts +13 -6
  55. package/lib/pipeline-gc.ts +105 -0
  56. package/lib/pipeline-scheduler.ts +29 -0
  57. package/lib/pipeline.ts +811 -330
  58. package/lib/schedules/action-runner.ts +257 -0
  59. package/lib/schedules/scheduler.ts +422 -0
  60. package/lib/schedules/state.ts +41 -0
  61. package/lib/schedules/store.ts +618 -0
  62. package/lib/schedules/types.ts +117 -0
  63. package/lib/settings.ts +35 -0
  64. package/lib/task-manager.ts +56 -13
  65. package/lib/workflow-marketplace.ts +7 -1
  66. package/lib/workspace/skill-installer.ts +7 -6
  67. package/package.json +3 -1
  68. package/lib/help-docs/19-jobs.md +0 -145
  69. package/lib/help-docs/20-mantis-bug-fix.md +0 -115
  70. package/lib/help-docs/22-recipes.md +0 -124
package/RELEASE_NOTES.md CHANGED
@@ -1,11 +1,66 @@
1
- # Forge v0.9.1
1
+ # Forge v0.9.2
2
2
 
3
- Released: 2026-05-22
3
+ Released: 2026-05-26
4
4
 
5
- ## Changes since v0.9.0
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
- - fix(settings): CLI/API profile add doesn't show + leaks into Agents
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)
9
64
 
10
65
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.0...v0.9.1
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
- void executeRun(prepared.job, prepared.runId).catch((err) => {
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({
@@ -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
- return NextResponse.json({ jobs: listJobs() });
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
+ }