@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
@@ -0,0 +1,173 @@
1
+ /**
2
+ * GET /api/schedules/:id single (decorated)
3
+ * PATCH /api/schedules/:id update name / input / schedule fields / enabled
4
+ * DELETE /api/schedules/:id?cancel_inflight=1
5
+ */
6
+
7
+ import { NextResponse } from 'next/server';
8
+ import {
9
+ getSchedule,
10
+ updateSchedule,
11
+ deleteSchedule,
12
+ listInflightForSchedule,
13
+ } from '@/lib/schedules/store';
14
+ import { decorateSchedule } from '@/lib/schedules/state';
15
+ import { listWorkflows, cancelPipeline } from '@/lib/pipeline';
16
+ import type { ScheduleKind, ScheduleActionKind, UpdateScheduleInput } from '@/lib/schedules/types';
17
+
18
+ const VALID_KINDS: ScheduleKind[] = ['period', 'once', 'cron', 'manual'];
19
+ const VALID_ACTION_KINDS: ScheduleActionKind[] = ['none', 'chat', 'email', 'telegram'];
20
+
21
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
22
+ const { id } = await params;
23
+ const s = getSchedule(id);
24
+ if (!s) return NextResponse.json({ error: 'schedule not found' }, { status: 404 });
25
+ return NextResponse.json({ schedule: decorateSchedule(s) });
26
+ }
27
+
28
+ export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
29
+ const { id } = await params;
30
+ const existing = getSchedule(id);
31
+ if (!existing) return NextResponse.json({ error: 'schedule not found' }, { status: 404 });
32
+
33
+ let body: any = {};
34
+ try { body = await req.json(); } catch {}
35
+
36
+ // body_ref + body_kind are immutable — must build a new schedule
37
+ // to change what the schedule fires. Accept legacy pipeline_name as
38
+ // an alias for the body_ref equality check.
39
+ const incomingBodyRef = body.body_ref ?? body.pipeline_name;
40
+ if (incomingBodyRef && incomingBodyRef !== existing.body_ref) {
41
+ return NextResponse.json({
42
+ error: 'body_ref cannot be changed; create a new schedule instead',
43
+ }, { status: 400 });
44
+ }
45
+ if (body.body_kind && body.body_kind !== existing.body_kind) {
46
+ return NextResponse.json({
47
+ error: 'body_kind cannot be changed; create a new schedule instead',
48
+ }, { status: 400 });
49
+ }
50
+
51
+ const patch: UpdateScheduleInput = {};
52
+
53
+ if (typeof body.name === 'string') patch.name = body.name.trim();
54
+ if (typeof body.enabled === 'boolean') patch.enabled = body.enabled;
55
+
56
+ if (body.input !== undefined) {
57
+ if (!body.input || typeof body.input !== 'object' || Array.isArray(body.input)) {
58
+ return NextResponse.json({ error: 'input must be an object' }, { status: 400 });
59
+ }
60
+ // For pipeline body, validate keys against pipeline.input declarations.
61
+ // Skill / connector_tool bodies have free-form input — no validation here yet.
62
+ if (existing.body_kind === 'pipeline') {
63
+ const workflow = listWorkflows().find((w) => w.name === existing.body_ref);
64
+ if (workflow) {
65
+ const declared = new Set(Object.keys(workflow.input || {}));
66
+ for (const k of Object.keys(body.input)) {
67
+ if (!declared.has(k)) {
68
+ return NextResponse.json({
69
+ error: `input field "${k}" is not declared by pipeline "${existing.body_ref}"`,
70
+ }, { status: 400 });
71
+ }
72
+ }
73
+ }
74
+ }
75
+ patch.input = body.input;
76
+ }
77
+
78
+ if (body.skills !== undefined) {
79
+ if (!Array.isArray(body.skills)) {
80
+ return NextResponse.json({ error: 'skills must be an array of skill names' }, { status: 400 });
81
+ }
82
+ patch.skills = body.skills
83
+ .filter((s: unknown) => typeof s === 'string' && s.trim())
84
+ .map((s: string) => s.trim());
85
+ }
86
+
87
+ if (body.schedule_kind !== undefined) {
88
+ if (!VALID_KINDS.includes(body.schedule_kind)) {
89
+ return NextResponse.json({ error: 'schedule_kind must be one of: ' + VALID_KINDS.join(', ') }, { status: 400 });
90
+ }
91
+ patch.schedule_kind = body.schedule_kind;
92
+ }
93
+ if (body.schedule_interval_minutes !== undefined && body.schedule_interval_minutes !== null) {
94
+ // null is the client's way of saying "this trigger kind doesn't need
95
+ // an interval" (manual / cron / once), so skip — don't reject it.
96
+ const n = Number(body.schedule_interval_minutes);
97
+ if (!Number.isFinite(n) || n < 1) {
98
+ return NextResponse.json({ error: 'schedule_interval_minutes must be ≥ 1' }, { status: 400 });
99
+ }
100
+ patch.schedule_interval_minutes = Math.max(1, Math.trunc(n));
101
+ }
102
+ if (body.schedule_at !== undefined) patch.schedule_at = body.schedule_at || null;
103
+ if (body.schedule_cron !== undefined) patch.schedule_cron = body.schedule_cron || null;
104
+
105
+ // Action — same validation as POST. action_config is replaced wholesale
106
+ // (not merged) when supplied, so the caller must send the full new shape.
107
+ if (body.action_kind !== undefined) {
108
+ if (!VALID_ACTION_KINDS.includes(body.action_kind)) {
109
+ return NextResponse.json({ error: 'action_kind must be one of: ' + VALID_ACTION_KINDS.join(', ') }, { status: 400 });
110
+ }
111
+ patch.action_kind = body.action_kind;
112
+ }
113
+ if (body.action_config !== undefined) {
114
+ if (!body.action_config || typeof body.action_config !== 'object' || Array.isArray(body.action_config)) {
115
+ return NextResponse.json({ error: 'action_config must be an object' }, { status: 400 });
116
+ }
117
+ patch.action_config = body.action_config;
118
+ }
119
+ const effectiveActionKind = patch.action_kind ?? existing.action_kind;
120
+ const effectiveActionConfig = patch.action_config ?? existing.action_config ?? {};
121
+ if (effectiveActionKind === 'chat') {
122
+ const sid = typeof effectiveActionConfig.session_id === 'string' ? effectiveActionConfig.session_id.trim() : '';
123
+ if (!sid) return NextResponse.json({ error: 'action_kind=chat requires action_config.session_id' }, { status: 400 });
124
+ }
125
+ if (effectiveActionKind === 'email') {
126
+ const toRaw = effectiveActionConfig.to;
127
+ const to = Array.isArray(toRaw)
128
+ ? toRaw.filter((x) => typeof x === 'string' && x.trim())
129
+ : (typeof toRaw === 'string' && toRaw.trim() ? [toRaw] : []);
130
+ if (to.length === 0) {
131
+ return NextResponse.json({ error: 'action_kind=email requires action_config.to (recipient address or array)' }, { status: 400 });
132
+ }
133
+ }
134
+
135
+ // Cross-field consistency
136
+ const effectiveKind = patch.schedule_kind ?? existing.schedule_kind;
137
+ if (effectiveKind === 'cron' && !(patch.schedule_cron ?? existing.schedule_cron)) {
138
+ return NextResponse.json({ error: 'schedule_cron is required when schedule_kind=cron' }, { status: 400 });
139
+ }
140
+ if (effectiveKind === 'once' && !(patch.schedule_at ?? existing.schedule_at)) {
141
+ return NextResponse.json({ error: 'schedule_at is required when schedule_kind=once' }, { status: 400 });
142
+ }
143
+
144
+ updateSchedule(id, patch);
145
+ return NextResponse.json({ schedule: decorateSchedule(getSchedule(id)!) });
146
+ }
147
+
148
+ export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
149
+ const { id } = await params;
150
+ const s = getSchedule(id);
151
+ if (!s) return NextResponse.json({ error: 'schedule not found' }, { status: 404 });
152
+
153
+ const url = new URL(req.url);
154
+ const cancelInflight = url.searchParams.get('cancel_inflight') === '1';
155
+
156
+ let cancelledPipelines = 0;
157
+ if (cancelInflight) {
158
+ for (const run of listInflightForSchedule(id)) {
159
+ // Phase 1: target_id is always a pipeline_id. When skill /
160
+ // connector_tool body lands, dispatch cancel by body_kind.
161
+ try { if (cancelPipeline(run.target_id)) cancelledPipelines++; }
162
+ catch (e) {
163
+ console.warn(`[schedules/delete] cancelPipeline(${run.target_id}) failed: ${(e as Error).message}`);
164
+ }
165
+ }
166
+ }
167
+ const ok = deleteSchedule(id);
168
+ return NextResponse.json({
169
+ ok,
170
+ cancelled_pipelines: cancelledPipelines,
171
+ cancel_inflight_requested: cancelInflight,
172
+ });
173
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * POST /api/schedules/:id/run[?cancel_inflight=1]
3
+ *
4
+ * Manual fire (trigger='manual'). 409 if busy unless cancel_inflight=1.
5
+ */
6
+
7
+ import { NextResponse } from 'next/server';
8
+ import { getSchedule, listInflightForSchedule } from '@/lib/schedules/store';
9
+ import { executeSchedule, isScheduleBusy } from '@/lib/schedules/scheduler';
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
+ const url = new URL(req.url);
18
+ const cancelInflight = url.searchParams.get('cancel_inflight') === '1';
19
+
20
+ const busy = isScheduleBusy(id);
21
+ if (busy.busy) {
22
+ if (!cancelInflight) {
23
+ return NextResponse.json({
24
+ error: `schedule is busy: ${busy.reason}. Pass ?cancel_inflight=1 to cancel + run anyway.`,
25
+ busy: true,
26
+ reason: busy.reason,
27
+ }, { status: 409 });
28
+ }
29
+ // Cancel each inflight pipeline before firing a new one.
30
+ // Phase 1: target_id is always a pipeline_id.
31
+ for (const run of listInflightForSchedule(id)) {
32
+ try { cancelPipeline(run.target_id); }
33
+ catch (e) {
34
+ console.warn(`[schedules/run] cancelPipeline(${run.target_id}) failed: ${(e as Error).message}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ try {
40
+ const targetId = await executeSchedule(s, 'manual');
41
+ return NextResponse.json({ target_id: targetId, pipeline_id: targetId }, { status: 202 });
42
+ } catch (e) {
43
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
44
+ }
45
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * GET /api/schedules/:id/runs?limit=20
3
+ *
4
+ * Recent schedule_runs for this schedule, newest first.
5
+ */
6
+
7
+ import { NextResponse } from 'next/server';
8
+ import { getSchedule, listScheduleRuns } from '@/lib/schedules/store';
9
+
10
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params;
12
+ const s = getSchedule(id);
13
+ if (!s) return NextResponse.json({ error: 'schedule not found' }, { status: 404 });
14
+
15
+ const url = new URL(req.url);
16
+ const limitParam = url.searchParams.get('limit');
17
+ const limit = limitParam && Number.isFinite(Number(limitParam))
18
+ ? Math.min(Math.max(Number(limitParam), 1), 200)
19
+ : 20;
20
+
21
+ return NextResponse.json({ runs: listScheduleRuns(id, limit) });
22
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * POST /api/schedules/:id/stop
3
+ *
4
+ * One-shot Pause + Cancel: disables the schedule AND cancels every
5
+ * inflight pipeline it launched. Use when user wants to fully halt.
6
+ */
7
+
8
+ import { NextResponse } from 'next/server';
9
+ import {
10
+ getSchedule,
11
+ updateSchedule,
12
+ listInflightForSchedule,
13
+ } from '@/lib/schedules/store';
14
+ import { cancelPipeline } from '@/lib/pipeline';
15
+
16
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
17
+ const { id } = await params;
18
+ const s = getSchedule(id);
19
+ if (!s) return NextResponse.json({ error: 'schedule not found' }, { status: 404 });
20
+
21
+ updateSchedule(id, { enabled: false });
22
+
23
+ let cancelled = 0;
24
+ for (const run of listInflightForSchedule(id)) {
25
+ // Phase 1: target_id is always a pipeline_id.
26
+ try { if (cancelPipeline(run.target_id)) cancelled++; }
27
+ catch (e) {
28
+ console.warn(`[schedules/stop] cancelPipeline(${run.target_id}) failed: ${(e as Error).message}`);
29
+ }
30
+ }
31
+
32
+ return NextResponse.json({ ok: true, cancelled });
33
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * GET /api/schedules list with decorated state fields
3
+ * POST /api/schedules create
4
+ *
5
+ * V2: body is described by body_kind + body_ref instead of pipeline_name.
6
+ * Phase 1 only accepts body_kind='pipeline' (and defaults to it if
7
+ * omitted) so existing clients posting pipeline_name still work via a
8
+ * compat alias. skill / connector_tool bodies land in later phases.
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import {
13
+ listSchedules,
14
+ createSchedule,
15
+ reconcileStaleScheduleRuns,
16
+ } from '@/lib/schedules/store';
17
+ import { decorateSchedule } from '@/lib/schedules/state';
18
+ import { listWorkflows } from '@/lib/pipeline';
19
+ import { listSkills } from '@/lib/skills';
20
+ import { getProjectInfo } from '@/lib/projects';
21
+ import { getInstalledConnector } from '@/lib/connectors/registry';
22
+ import type {
23
+ ScheduleKind,
24
+ ScheduleBodyKind,
25
+ ScheduleActionKind,
26
+ CreateScheduleInput,
27
+ } from '@/lib/schedules/types';
28
+
29
+ export async function GET() {
30
+ // Reconcile zombies before reading so UI sees correct inflight counts.
31
+ reconcileStaleScheduleRuns();
32
+ const schedules = listSchedules().map(decorateSchedule);
33
+ return NextResponse.json({ schedules });
34
+ }
35
+
36
+ const VALID_KINDS: ScheduleKind[] = ['period', 'once', 'cron', 'manual'];
37
+ const VALID_BODY_KINDS: ScheduleBodyKind[] = ['pipeline', 'skill', 'connector_tool'];
38
+ const VALID_ACTION_KINDS: ScheduleActionKind[] = ['none', 'chat', 'email', 'telegram'];
39
+
40
+ export async function POST(req: Request) {
41
+ let body: any = {};
42
+ try { body = await req.json(); } catch {}
43
+
44
+ // Required
45
+ if (!body?.name || typeof body.name !== 'string') {
46
+ return NextResponse.json({ error: 'name is required' }, { status: 400 });
47
+ }
48
+
49
+ // Compat: accept body_ref OR legacy pipeline_name (with body_kind defaulting to 'pipeline').
50
+ const bodyRef: string | undefined = body.body_ref || body.pipeline_name;
51
+ const bodyKind: ScheduleBodyKind = VALID_BODY_KINDS.includes(body.body_kind)
52
+ ? body.body_kind : 'pipeline';
53
+
54
+ if (!bodyRef || typeof bodyRef !== 'string') {
55
+ return NextResponse.json({ error: 'body_ref is required' }, { status: 400 });
56
+ }
57
+ const input = (body.input && typeof body.input === 'object' && !Array.isArray(body.input))
58
+ ? body.input as Record<string, unknown> : {};
59
+
60
+ if (bodyKind === 'pipeline') {
61
+ // Pipeline must exist + input shape check
62
+ const workflow = listWorkflows().find((w) => w.name === bodyRef);
63
+ if (!workflow) {
64
+ return NextResponse.json({ error: `pipeline "${bodyRef}" not found` }, { status: 400 });
65
+ }
66
+ const declaredKeys = new Set(Object.keys(workflow.input || {}));
67
+ for (const k of Object.keys(input)) {
68
+ if (!declaredKeys.has(k)) {
69
+ return NextResponse.json({
70
+ error: `input field "${k}" is not declared by pipeline "${bodyRef}". Allowed: ${Array.from(declaredKeys).join(', ')}`,
71
+ }, { status: 400 });
72
+ }
73
+ }
74
+ } else if (bodyKind === 'skill') {
75
+ // Skill must be installed
76
+ const installed = listSkills().some((sk) => sk.name === bodyRef && (sk.installedGlobal || (sk.installedProjects && sk.installedProjects.length > 0)));
77
+ if (!installed) {
78
+ return NextResponse.json({ error: `skill "${bodyRef}" is not installed` }, { status: 400 });
79
+ }
80
+ // input.project is required and must resolve
81
+ const projectName = typeof input.project === 'string' ? input.project.trim() : '';
82
+ if (!projectName) {
83
+ return NextResponse.json({
84
+ error: 'skill body requires input.project (a Forge project name)',
85
+ }, { status: 400 });
86
+ }
87
+ if (!getProjectInfo(projectName)) {
88
+ return NextResponse.json({
89
+ error: `project "${projectName}" not found in Forge's projects list`,
90
+ }, { status: 400 });
91
+ }
92
+ } else if (bodyKind === 'connector_tool') {
93
+ // body_ref is "<plugin_id>.<tool_name>"
94
+ const dot = bodyRef.indexOf('.');
95
+ if (dot <= 0 || dot === bodyRef.length - 1) {
96
+ return NextResponse.json({
97
+ error: 'connector_tool body_ref must be "<plugin_id>.<tool_name>"',
98
+ }, { status: 400 });
99
+ }
100
+ const pluginId = bodyRef.slice(0, dot);
101
+ const toolName = bodyRef.slice(dot + 1);
102
+ const connector = getInstalledConnector(pluginId);
103
+ if (!connector || !connector.enabled) {
104
+ return NextResponse.json({
105
+ error: `connector "${pluginId}" is not installed or disabled`,
106
+ }, { status: 400 });
107
+ }
108
+ const tools = connector.definition.tools || {};
109
+ if (!tools[toolName]) {
110
+ return NextResponse.json({
111
+ error: `tool "${toolName}" not found on connector "${pluginId}". Available: ${Object.keys(tools).join(', ')}`,
112
+ }, { status: 400 });
113
+ }
114
+ }
115
+
116
+ // Action — phase 4 ships chat. email/telegram land in phases 5-6.
117
+ const actionKind: ScheduleActionKind = VALID_ACTION_KINDS.includes(body.action_kind)
118
+ ? body.action_kind : 'none';
119
+ const actionConfig = (body.action_config && typeof body.action_config === 'object' && !Array.isArray(body.action_config))
120
+ ? body.action_config as Record<string, unknown> : {};
121
+ if (actionKind === 'chat') {
122
+ const sessionId = typeof actionConfig.session_id === 'string' ? actionConfig.session_id.trim() : '';
123
+ if (!sessionId) {
124
+ return NextResponse.json({
125
+ error: 'action_kind=chat requires action_config.session_id',
126
+ }, { status: 400 });
127
+ }
128
+ }
129
+ if (actionKind === 'email') {
130
+ const toRaw = actionConfig.to;
131
+ const to = Array.isArray(toRaw)
132
+ ? toRaw.filter((x) => typeof x === 'string' && x.trim())
133
+ : (typeof toRaw === 'string' && toRaw.trim() ? [toRaw] : []);
134
+ if (to.length === 0) {
135
+ return NextResponse.json({
136
+ error: 'action_kind=email requires action_config.to (recipient address or array)',
137
+ }, { status: 400 });
138
+ }
139
+ }
140
+
141
+ // Schedule kind + consistency
142
+ const kind: ScheduleKind = VALID_KINDS.includes(body.schedule_kind) ? body.schedule_kind : 'period';
143
+ if (kind === 'cron' && !body.schedule_cron) {
144
+ return NextResponse.json({ error: 'schedule_cron is required when schedule_kind=cron' }, { status: 400 });
145
+ }
146
+ if (kind === 'once' && !body.schedule_at) {
147
+ return NextResponse.json({ error: 'schedule_at is required when schedule_kind=once' }, { status: 400 });
148
+ }
149
+
150
+ // Skills (optional). Array of installed skill names; forwarded as
151
+ // --append-system-prompt to the dispatched body's task(s). Ignored for
152
+ // body_kind=connector_tool (no Claude task there).
153
+ const skills: string[] = Array.isArray(body.skills)
154
+ ? body.skills.filter((s: unknown) => typeof s === 'string' && s.trim()).map((s: string) => s.trim())
155
+ : [];
156
+
157
+ const input2: CreateScheduleInput = {
158
+ name: body.name.trim(),
159
+ body_kind: bodyKind,
160
+ body_ref: bodyRef,
161
+ input,
162
+ skills,
163
+ action_kind: actionKind,
164
+ action_config: actionConfig,
165
+ action_skip_on_empty: !!body.action_skip_on_empty,
166
+ enabled: body.enabled !== false,
167
+ schedule_kind: kind,
168
+ schedule_interval_minutes: Number.isFinite(Number(body.schedule_interval_minutes))
169
+ ? Math.max(1, Number(body.schedule_interval_minutes)) : 30,
170
+ schedule_at: body.schedule_at || null,
171
+ schedule_cron: body.schedule_cron || null,
172
+ };
173
+ const created = createSchedule(input2);
174
+ return NextResponse.json({ schedule: decorateSchedule(created) });
175
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * POST /api/tasks/bulk-delete
3
+ *
4
+ * { older_than_days?: number, before?: ISO, statuses?: string[] }
5
+ *
6
+ * Deletes tasks matching the filter. Running tasks are skipped — only
7
+ * terminal-state rows (done/failed/cancelled by default) are removed.
8
+ *
9
+ * older_than_days: 7 → cutoff = now - 7 days
10
+ * before: "ISO" → explicit cutoff
11
+ * statuses: ["failed"] → narrow to just failures (default = done+failed+cancelled)
12
+ *
13
+ * Returns { removed: <count>, before: <ISO cutoff> }.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+ import { bulkDeleteTasks } from '@/lib/task-manager';
18
+ import type { TaskStatus } from '@/src/types';
19
+
20
+ export async function POST(req: Request) {
21
+ let body: any = {};
22
+ try { body = await req.json(); } catch {}
23
+
24
+ let before: string;
25
+ if (typeof body.before === 'string' && body.before) {
26
+ before = body.before;
27
+ } else if (Number.isFinite(Number(body.older_than_days))) {
28
+ const days = Math.max(0, Number(body.older_than_days));
29
+ before = new Date(Date.now() - days * 86_400_000).toISOString();
30
+ } else {
31
+ return NextResponse.json({ error: 'pass `older_than_days` (number) or `before` (ISO timestamp)' }, { status: 400 });
32
+ }
33
+
34
+ const validStatuses: TaskStatus[] = ['queued', 'running', 'done', 'failed', 'cancelled'];
35
+ let statuses: TaskStatus[] | undefined;
36
+ if (Array.isArray(body.statuses) && body.statuses.length) {
37
+ statuses = (body.statuses as string[])
38
+ .filter((s) => validStatuses.includes(s as TaskStatus))
39
+ .filter((s) => s !== 'running' && s !== 'queued') as TaskStatus[];
40
+ if (statuses.length === 0) {
41
+ return NextResponse.json({ error: 'statuses filter excluded everything (running/queued can never be bulk-deleted)' }, { status: 400 });
42
+ }
43
+ }
44
+
45
+ const removed = bulkDeleteTasks({ before, statuses });
46
+ return NextResponse.json({ removed, before, statuses: statuses ?? ['done', 'failed', 'cancelled'] });
47
+ }
@@ -329,6 +329,28 @@ function cleanupOrphans() {
329
329
  try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
330
330
  }
331
331
  } catch {}
332
+ // Kill zombie tsx scripts holding workflow.db open. Any tsx script that
333
+ // imported lib/task-manager (directly or via lib/pipeline) starts its
334
+ // own setInterval task runner that never exits — those run in parallel
335
+ // with the real runner and silently steal tasks. Detect via lsof on
336
+ // workflow.db, exclude our own next-server + standalones.
337
+ try {
338
+ const dbPath = join(DATA_DIR, 'workflow.db');
339
+ const lsofOut = execSync(`lsof -t "${dbPath}"`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
340
+ for (const pid of lsofOut.split('\n').map(s => s.trim()).filter(Boolean)) {
341
+ if (pid === myPid || protectedPids.has(pid)) continue;
342
+ let cmd = '';
343
+ try {
344
+ cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
345
+ } catch { continue; }
346
+ // Skip legit holders: next-server + our standalones (handled above)
347
+ if (/next-server|next start|telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone/.test(cmd)) continue;
348
+ // Only kill tsx-loaded scripts (typical zombie debug runner shape)
349
+ if (!/tsx/.test(cmd)) continue;
350
+ console.log(`[forge] Killing zombie task-runner (pid=${pid}): ${cmd.slice(0, 120)}`);
351
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
352
+ }
353
+ } catch {}
332
354
  } catch {}
333
355
  }
334
356
 
@@ -464,7 +486,6 @@ function startBackground() {
464
486
  console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
465
487
  console.log(`[forge] Data: ${DATA_DIR}`);
466
488
  console.log(`[forge] Log: ${LOG_FILE}`);
467
- console.log(`[forge] Stop: forge server stop`);
468
489
  }
469
490
 
470
491
  // ── Stop ──