@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.
Files changed (70) hide show
  1. package/RELEASE_NOTES.md +60 -7
  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 +116 -7
  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,117 @@
1
+ /**
2
+ * Forge Schedules — types (V2)
3
+ *
4
+ * V2 generalizes Schedule from "pipeline + input + trigger" to
5
+ * "trigger + body + action". body is one of pipeline / skill /
6
+ * connector_tool (skill + connector_tool land in later phases).
7
+ * action is one of none / chat / email / telegram (chat/email/telegram
8
+ * land in later phases).
9
+ *
10
+ * Phase 1 only renames + extends fields; runtime behavior unchanged
11
+ * (body_kind defaults to 'pipeline', action_kind defaults to 'none').
12
+ */
13
+
14
+ export type ScheduleKind = 'period' | 'once' | 'cron' | 'manual';
15
+ export type ScheduleRunStatus = 'started' | 'done' | 'failed' | 'cancelled';
16
+ export type ScheduleRunTrigger = 'schedule' | 'manual';
17
+
18
+ export type ScheduleBodyKind = 'pipeline' | 'skill' | 'connector_tool';
19
+ export type ScheduleActionKind = 'none' | 'chat' | 'email' | 'telegram';
20
+ export type ScheduleActionStatus = 'pending' | 'done' | 'failed' | 'skipped';
21
+
22
+ /** Persisted Schedule config. status is NOT here — computed on read. */
23
+ export interface Schedule {
24
+ id: string;
25
+ name: string;
26
+ enabled: boolean;
27
+
28
+ // Body — what to run when this schedule fires.
29
+ body_kind: ScheduleBodyKind;
30
+ /** Reference depends on body_kind: pipeline name / skill id / `<plugin>.<tool>`. */
31
+ body_ref: string;
32
+ /** Input params; shape depends on body_kind. */
33
+ input: Record<string, unknown>;
34
+
35
+ /** Extra Forge skills attached to the dispatched body. For body=pipeline
36
+ * these are forwarded to startPipeline({skills}) → every task gets the
37
+ * --append-system-prompt block. For body=skill they're merged with
38
+ * body_ref into a single prompt. body=connector_tool ignores this. */
39
+ skills: string[];
40
+
41
+ // Action — what to do with body's output. action_config JSON shape
42
+ // is kind-specific (see help doc 13-schedules.md).
43
+ action_kind: ScheduleActionKind;
44
+ action_config: Record<string, unknown>;
45
+ action_skip_on_empty: boolean;
46
+
47
+ // Trigger
48
+ schedule_kind: ScheduleKind;
49
+ schedule_interval_minutes: number;
50
+ schedule_at: string | null;
51
+ schedule_cron: string | null;
52
+ next_run_at: string | null;
53
+ last_run_at: string | null;
54
+
55
+ created_at: string;
56
+ updated_at: string;
57
+ }
58
+
59
+ /** UI-facing schedule with computed state fields. */
60
+ export type ActiveState = 'idle' | 'running' | 'last_failed' | 'paused';
61
+
62
+ export interface DecoratedSchedule extends Schedule {
63
+ /** Number of schedule_runs.status='started' for this schedule. */
64
+ inflight_count: number;
65
+ /** Most recent terminal run's status, null if never run. */
66
+ last_status: ScheduleRunStatus | null;
67
+ /** Computed from enabled + inflight + last_status. */
68
+ active_state: ActiveState;
69
+ }
70
+
71
+ export interface ScheduleRun {
72
+ id: string;
73
+ schedule_id: string;
74
+ /** ID of the dispatched body — pipeline_id for body=pipeline,
75
+ * task_id for body=skill, fresh uuid for body=connector_tool. */
76
+ target_id: string;
77
+ trigger: ScheduleRunTrigger;
78
+ status: ScheduleRunStatus;
79
+ /** Captured output of the body — fed into action. May be null
80
+ * while body is still running, or if body produced no output. */
81
+ body_output: string | null;
82
+ action_status: ScheduleActionStatus | null;
83
+ action_error: string | null;
84
+ started_at: string;
85
+ finished_at: string | null;
86
+ error: string | null;
87
+ }
88
+
89
+ export interface CreateScheduleInput {
90
+ name: string;
91
+ body_kind?: ScheduleBodyKind;
92
+ body_ref: string;
93
+ input?: Record<string, unknown>;
94
+ skills?: string[];
95
+ action_kind?: ScheduleActionKind;
96
+ action_config?: Record<string, unknown>;
97
+ action_skip_on_empty?: boolean;
98
+ enabled?: boolean;
99
+ schedule_kind?: ScheduleKind;
100
+ schedule_interval_minutes?: number;
101
+ schedule_at?: string | null;
102
+ schedule_cron?: string | null;
103
+ }
104
+
105
+ export interface UpdateScheduleInput {
106
+ name?: string;
107
+ input?: Record<string, unknown>;
108
+ skills?: string[];
109
+ enabled?: boolean;
110
+ action_kind?: ScheduleActionKind;
111
+ action_config?: Record<string, unknown>;
112
+ action_skip_on_empty?: boolean;
113
+ schedule_kind?: ScheduleKind;
114
+ schedule_interval_minutes?: number;
115
+ schedule_at?: string | null;
116
+ schedule_cron?: string | null;
117
+ }
package/lib/settings.ts CHANGED
@@ -117,6 +117,31 @@ export interface Settings {
117
117
  * Used by the extension's date formatter for chat / jobs / pipeline run timestamps.
118
118
  */
119
119
  timezone: string;
120
+ /**
121
+ * SMTP transport for Schedule action=email (and any future email-out
122
+ * paths). Flat fields so smtpPassword fits SECRET_FIELDS encryption.
123
+ * Empty smtpHost disables the email channel.
124
+ * smtpSecure: true → implicit TLS (port 465).
125
+ * false + port 587 → STARTTLS.
126
+ * smtpFrom: e.g. "Forge <noreply@example.com>"; falls back to smtpUser.
127
+ */
128
+ smtpHost: string;
129
+ smtpPort: number;
130
+ smtpSecure: boolean;
131
+ smtpUser: string;
132
+ smtpPassword: string;
133
+ smtpFrom: string;
134
+ /**
135
+ * Pipeline tmp dir GC — controls retention of `<project>/worktrees/pipeline-<id>/`
136
+ * directories that nodes write scratch files to via `{{run.tmp_dir}}`.
137
+ * pipelineTmpCleanDoneImmediate: true (default) → wipe immediately when pipeline status flips to 'done'
138
+ * pipelineTmpKeepFailedDays / pipelineTmpKeepCancelledDays: keep these many days, then sweep
139
+ * pipelineTmpGcIntervalHours: how often the background sweep runs (clamped to >= 1h)
140
+ */
141
+ pipelineTmpCleanDoneImmediate: boolean;
142
+ pipelineTmpKeepFailedDays: number;
143
+ pipelineTmpKeepCancelledDays: number;
144
+ pipelineTmpGcIntervalHours: number;
120
145
  }
121
146
 
122
147
  const defaults: Settings = {
@@ -154,6 +179,16 @@ const defaults: Settings = {
154
179
  agents: {},
155
180
  mcpServers: {},
156
181
  timezone: '',
182
+ smtpHost: '',
183
+ smtpPort: 587,
184
+ smtpSecure: false,
185
+ smtpUser: '',
186
+ smtpPassword: '',
187
+ smtpFrom: '',
188
+ pipelineTmpCleanDoneImmediate: true,
189
+ pipelineTmpKeepFailedDays: 3,
190
+ pipelineTmpKeepCancelledDays: 3,
191
+ pipelineTmpGcIntervalHours: 6,
157
192
  };
158
193
 
159
194
  /** Decrypt nested apiKey fields in agent profiles */
@@ -6,14 +6,27 @@
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { spawn, execSync } from 'node:child_process';
8
8
  import { realpathSync } from 'node:fs';
9
+ import * as pty from 'node-pty';
9
10
  import { getDb } from '@/src/core/db/database';
10
11
  import { getDbPath } from '@/src/config';
11
12
  import { loadSettings } from './settings';
12
13
  import { notifyTaskComplete, notifyTaskFailed } from './notify';
14
+ import { getInstalledConnector } from './connectors/registry';
15
+ import { getAgent } from './agents';
16
+ import { recordUsage } from './usage-scanner';
13
17
  import type { Task, TaskLogEntry, TaskStatus, TaskMode, WatchConfig } from '@/src/types';
14
18
 
15
19
  import { toIsoUTC } from './iso-time';
16
20
 
21
+ /** Access pipeline.ts's pipelineTaskIds Set via the shared globalThis
22
+ * Symbol it registers at module load. Avoids the circular static
23
+ * import (pipeline.ts → task-manager). Returns empty Set if pipeline.ts
24
+ * hasn't initialized — caller treats as "not a pipeline task". */
25
+ function pipelineTaskIdsRef(): Set<string> {
26
+ const key = Symbol.for('mw-pipeline-task-ids');
27
+ return (globalThis as any)[key] || new Set<string>();
28
+ }
29
+
17
30
  const runnerKey = Symbol.for('mw-task-runner');
18
31
  const gRunner = globalThis as any;
19
32
  if (!gRunner[runnerKey]) gRunner[runnerKey] = { runner: null, currentTaskId: null };
@@ -261,6 +274,29 @@ export function deleteTask(id: string): boolean {
261
274
  return true;
262
275
  }
263
276
 
277
+ /** Bulk-delete completed tasks older than the given cutoff. Skips
278
+ * anything currently running so we never yank state from under the
279
+ * task runner. Returns the count removed. */
280
+ export interface BulkDeleteTasksFilter {
281
+ /** ISO timestamp; rows with created_at < this are eligible. */
282
+ before: string;
283
+ /** Statuses to include. Default ['done','failed','cancelled']. */
284
+ statuses?: TaskStatus[];
285
+ }
286
+
287
+ export function bulkDeleteTasks(filter: BulkDeleteTasksFilter): number {
288
+ const statuses = filter.statuses && filter.statuses.length
289
+ ? filter.statuses
290
+ : (['done', 'failed', 'cancelled'] as TaskStatus[]);
291
+ const placeholders = statuses.map(() => '?').join(',');
292
+ const r = db().prepare(
293
+ `DELETE FROM tasks
294
+ WHERE status IN (${placeholders})
295
+ AND created_at < ?`,
296
+ ).run(...statuses, filter.before);
297
+ return r.changes;
298
+ }
299
+
264
300
  export function updateTask(id: string, updates: { prompt?: string; projectName?: string; projectPath?: string; priority?: number; scheduledAt?: string; restart?: boolean }): Task | null {
265
301
  const task = getTask(id);
266
302
  if (!task) return null;
@@ -349,8 +385,13 @@ async function processNextTask() {
349
385
  // Execute async — don't await so we can process tasks for other projects in parallel
350
386
  executeTask(task)
351
387
  .catch((err: any) => {
352
- appendLog(task.id, { type: 'system', subtype: 'error', content: err.message, timestamp: new Date().toISOString() });
353
- updateTaskStatus(task.id, 'failed', err.message);
388
+ // Verbose diagnostic dump the stack alone hid an issue where
389
+ // err.stack was undefined (zombie tsx-runner-process throws had
390
+ // no stack); name/typeof/keys helped pin it down. Keep verbose.
391
+ const stack = err?.stack || err?.message || String(err);
392
+ console.error(`[task-runner] executeTask failed for ${task.id}:`, JSON.stringify({ msg: err?.message, name: err?.name, stack: err?.stack, str: String(err), type: typeof err, keys: err && Object.keys(err) }));
393
+ appendLog(task.id, { type: 'system', subtype: 'error', content: stack, timestamp: new Date().toISOString() });
394
+ updateTaskStatus(task.id, 'failed', err?.message || String(err));
354
395
  })
355
396
  .finally(() => {
356
397
  runningProjects.delete(task.projectName);
@@ -371,7 +412,6 @@ async function processNextTask() {
371
412
  */
372
413
  function connectorEnv(): Record<string, string> {
373
414
  try {
374
- const { getInstalledConnector } = require('./connectors/registry');
375
415
  const out: Record<string, string> = {};
376
416
  const gitlab = getInstalledConnector('gitlab');
377
417
  if (gitlab?.enabled) {
@@ -471,7 +511,6 @@ function executeTask(task: Task): Promise<void> {
471
511
 
472
512
  return new Promise((resolve, reject) => {
473
513
  const settings = loadSettings();
474
- const { getAgent } = require('./agents');
475
514
  const agentId = (task as any).agent || settings.defaultAgent || 'claude';
476
515
  const adapter = getAgent(agentId);
477
516
 
@@ -512,8 +551,9 @@ function executeTask(task: Task): Promise<void> {
512
551
  let ptyProcess: any = null;
513
552
 
514
553
  if (needsTTY) {
515
- // Use node-pty for agents that require a terminal environment
516
- const pty = require('node-pty');
554
+ // Use node-pty for agents that require a terminal environment.
555
+ // pty is imported at top level (was a runtime require() that
556
+ // hit "require is not defined" under ESM + concurrent loads).
517
557
  ptyProcess = pty.spawn(spawnOpts.cmd, spawnOpts.args, {
518
558
  name: 'xterm-256color',
519
559
  cols: 120,
@@ -584,8 +624,10 @@ function executeTask(task: Task): Promise<void> {
584
624
  let totalOutputTokens = 0;
585
625
 
586
626
  child.on('error', (err: any) => {
587
- console.error(`[task-runner] Spawn error:`, err.message);
588
- updateTaskStatus(task.id, 'failed', err.message);
627
+ const stack = err?.stack || err?.message || String(err);
628
+ console.error(`[task-runner] Spawn error for ${task.id}:`, stack);
629
+ appendLog(task.id, { type: 'system', subtype: 'error', content: `[spawn-error] ${stack}`, timestamp: new Date().toISOString() });
630
+ updateTaskStatus(task.id, 'failed', err?.message || String(err));
589
631
  reject(err);
590
632
  });
591
633
 
@@ -674,7 +716,6 @@ function executeTask(task: Task): Promise<void> {
674
716
  // 2. `git diff @{u}..HEAD` — commits ahead of upstream (worktree pushed nothing yet)
675
717
  // 3. `git show HEAD` — last commit's diff (fallback when no upstream tracking)
676
718
  try {
677
- const { execSync } = require('node:child_process');
678
719
  const tryDiff = (cmd: string): string => {
679
720
  try {
680
721
  return execSync(cmd, { cwd: task.projectPath, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
@@ -703,9 +744,8 @@ function executeTask(task: Task): Promise<void> {
703
744
  console.log(`[task] Done: ${task.id} ${task.projectName} (cost: $${totalCost?.toFixed(4) || '0'}, ${totalInputTokens}in/${totalOutputTokens}out)`);
704
745
  // Record usage
705
746
  try {
706
- const { recordUsage } = require('./usage-scanner');
707
747
  let isPipeline = false;
708
- try { const { pipelineTaskIds: ptids } = require('./pipeline'); isPipeline = ptids.has(task.id); } catch {}
748
+ isPipeline = pipelineTaskIdsRef().has(task.id);
709
749
  recordUsage({
710
750
  sessionId: sessionId || task.id,
711
751
  source: isPipeline ? 'pipeline' : 'task',
@@ -733,7 +773,10 @@ function executeTask(task: Task): Promise<void> {
733
773
  });
734
774
 
735
775
  child.on('error', (err: any) => {
736
- updateTaskStatus(task.id, 'failed', err.message);
776
+ const stack = err?.stack || err?.message || String(err);
777
+ console.error(`[task:shell] child error for ${task.id}:`, stack);
778
+ appendLog(task.id, { type: 'system', subtype: 'error', content: `[child-error] ${stack}`, timestamp: new Date().toISOString() });
779
+ updateTaskStatus(task.id, 'failed', err?.message || String(err));
737
780
  reject(err);
738
781
  });
739
782
  });
@@ -748,7 +791,7 @@ function executeTask(task: Task): Promise<void> {
748
791
  function notifyTerminalSession(task: Task, status: 'done' | 'failed', sessionId?: string) {
749
792
  // Skip pipeline tasks — they have their own notification system
750
793
  try {
751
- const { pipelineTaskIds } = require('./pipeline');
794
+ const pipelineTaskIds = pipelineTaskIdsRef();
752
795
  if (pipelineTaskIds.has(task.id)) return;
753
796
  } catch {}
754
797
 
@@ -190,7 +190,13 @@ async function fetchText(url: string): Promise<string | null> {
190
190
  const controller = new AbortController();
191
191
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
192
192
  try {
193
- const res = await fetch(url, { signal: controller.signal });
193
+ // Cache-bust + no-cache so a Sync click bypasses any intermediate
194
+ // HTTP cache and pulls the latest yaml from the repo CDN.
195
+ const sep = url.includes('?') ? '&' : '?';
196
+ const res = await fetch(`${url}${sep}_t=${Date.now()}`, {
197
+ signal: controller.signal,
198
+ headers: { 'Cache-Control': 'no-cache', 'User-Agent': 'forge-workflow-sync/1.0' },
199
+ });
194
200
  clearTimeout(timer);
195
201
  if (!res.ok) return null;
196
202
  return await res.text();
@@ -3,7 +3,7 @@
3
3
  * so they are available across all projects and sessions.
4
4
  */
5
5
 
6
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
7
7
  import { join, dirname } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { fileURLToPath } from 'node:url';
@@ -53,7 +53,9 @@ export function installForgeSkills(
53
53
  for (const file of files) {
54
54
  const flatFile = join(skillsDir, file);
55
55
  if (existsSync(flatFile)) {
56
- try { require('node:fs').unlinkSync(flatFile); } catch {}
56
+ try { unlinkSync(flatFile); } catch (e) {
57
+ console.warn(`[skill-installer] unlink ${flatFile} failed: ${(e as Error).message}`);
58
+ }
57
59
  }
58
60
  }
59
61
 
@@ -270,9 +272,8 @@ export function removeForgeSkills(projectPath: string): void {
270
272
  const forgeSkills = readdirSync(skillsDir).filter(f => f.startsWith('forge-'));
271
273
  for (const name of forgeSkills) {
272
274
  const p = join(skillsDir, name);
273
- try {
274
- const { rmSync } = require('node:fs');
275
- rmSync(p, { recursive: true, force: true });
276
- } catch {}
275
+ try { rmSync(p, { recursive: true, force: true }); } catch (e) {
276
+ console.warn(`[skill-installer] rmSync ${p} failed: ${(e as Error).message}`);
277
+ }
277
278
  }
278
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -51,6 +51,7 @@
51
51
  "next": "^16.2.1",
52
52
  "next-auth": "5.0.0-beta.30",
53
53
  "node-pty": "1.0.0",
54
+ "nodemailer": "^8.0.8",
54
55
  "react": "^19.2.4",
55
56
  "react-dom": "^19.2.4",
56
57
  "react-markdown": "^10.1.0",
@@ -70,6 +71,7 @@
70
71
  "@tailwindcss/postcss": "^4.2.1",
71
72
  "@types/better-sqlite3": "^7.6.13",
72
73
  "@types/node": "^25.4.0",
74
+ "@types/nodemailer": "^8.0.0",
73
75
  "@types/react": "^19.2.14",
74
76
  "@types/react-dom": "^19.2.3",
75
77
  "@types/ws": "^8.18.1",
@@ -1,145 +0,0 @@
1
- # Jobs
2
-
3
- A **Job** runs a connector tool on a schedule, dedups items, and dispatches
4
- each new item to a Pipeline or a Chat session.
5
-
6
- Typical examples:
7
- - Every 30 min, search Mantis for open bugs in version 25.4 → for each new
8
- bug, trigger the `bug-triage` pipeline bound to project `my-app`.
9
- - Every 5 min, list Teams messages in the current chat → for each new
10
- message, POST to a chat session that drafts a reply.
11
-
12
- Job is a separate primitive from Task and Pipeline. The CLI keeps
13
- `forge task` (single agent invocation) and `forge pipeline` (DAG of tasks)
14
- unchanged. `forge jobs` is new.
15
-
16
- > **Tip**: for common watchers (GitLab MR comments, Mantis bugs) prefer
17
- > **From recipe…** in the Jobs form — it pre-fills source connector,
18
- > dedup field, and pipeline wiring so you only fill 3-4 high-level
19
- > params. See `22-recipes.md` for the catalog.
20
-
21
- ## Anatomy
22
-
23
- | Field | Purpose |
24
- |---|---|
25
- | `source_connector`, `source_tool` | Which connector + tool to call each tick (e.g. `mantis.search_bugs`) |
26
- | `source_input` | JSON passed verbatim to the tool |
27
- | `items_path` | Dotted path into the tool's response to find the item array (`bugs`, empty = whole response if already an array) |
28
- | `dedup_field` | Field on each item used as the dedup key (e.g. `id`) |
29
- | `schedule_interval_minutes` | How often the scheduler ticks this job |
30
- | `dispatch_type` | `pipeline` or `chat` |
31
- | `dispatch_params` | Shape depends on dispatch_type — see below |
32
-
33
- ### `dispatch_type: pipeline`
34
-
35
- ```json
36
- {
37
- "workflow_name": "bug-triage",
38
- "project_path": "/Users/me/code/my-app",
39
- "project_name": "my-app",
40
- "input_template": {
41
- "bug_id": "{{item.id}}",
42
- "summary": "{{item.summary}}"
43
- }
44
- }
45
- ```
46
-
47
- Per new item, Forge renders `input_template` and calls the existing
48
- `triggerPipeline`. The pipeline shows up in the regular Pipelines view.
49
-
50
- ### `dispatch_type: chat`
51
-
52
- ```json
53
- {
54
- "agent_profile": "my-litellm",
55
- "session_title_template": "Bug {{item.id}}: {{item.summary}}",
56
- "message_template": "Triage bug {{item.id}}.\nSummary: {{item.summary}}",
57
- "reuse_session": false
58
- }
59
- ```
60
-
61
- Per new item, Forge creates (or reuses, if `reuse_session: true`) a chat
62
- session and POSTs a message rendered from `message_template`. The chat
63
- agent gets the message and decides what tools to call.
64
-
65
- ## Template syntax
66
-
67
- Only `{{item.<dotted.path>}}` is supported in v1. Missing paths render to
68
- empty string; objects render via `JSON.stringify`. `{{item}}` alone dumps
69
- the full item.
70
-
71
- ## Backfill guard
72
-
73
- New jobs default to `mark_existing_as_seen: true` — on first tick, every
74
- item the connector returns is added to `job_seen` but **no dispatch
75
- happens**. This stops a fresh job from firing on historical data. Set to
76
- `false` at create time to dispatch immediately on first tick.
77
-
78
- ## CLI
79
-
80
- ```
81
- forge jobs # list
82
- forge jobs show <id> # full JSON detail
83
- forge jobs runs <id> # recent ticks (summary line per run)
84
- forge jobs dispatches <id> <run_id> # per-item dispatches for one run (target ids)
85
- forge jobs run <id> # fire now (manual trigger)
86
- forge jobs enable <id>
87
- forge jobs disable <id>
88
- forge jobs reset <id> # wipe dedup state — next tick re-processes everything
89
- forge jobs rm <id>
90
- ```
91
-
92
- ## Tracking results
93
-
94
- | Want to know | Where |
95
- |---|---|
96
- | Job list, schedule, enabled, last/next run | Extension → Jobs tab |
97
- | Last 10 ticks summary (seen/new/dispatched, error) | Expand a job in Jobs tab |
98
- | Which items got dispatched in a single tick | Expand a run row — shows item_key + preview + target id + open-link |
99
- | What the resulting pipeline run did | Click "open →" on a pipeline dispatch — deep-links to Forge web Pipelines view |
100
- | What the resulting chat session said | Click "open →" on a chat dispatch — switches the side panel to chat tab and loads that session |
101
- | Run / dispatch detail from terminal | `forge jobs runs <id>` then `forge jobs dispatches <id> <run_id>` |
102
- | Raw rows | sqlite: `select * from job_runs / job_dispatches` |
103
- | Server-side log lines (every tick / error / dispatch) | Forge web → Logs view, search `[jobs]` (per-job: `[jobs] <job_id>`). The Jobs view has "View logs" buttons that pre-fill this filter. CLI: `tail -f ~/.forge/data/forge.log \| grep '\[jobs\]'`. |
104
- | Why a run shows 0 dispatches | Read the italic note under the run row — the scheduler writes a `notes` field explaining backfill / non-JSON response / items_path mismatch. |
105
-
106
- Create + edit happen via the Forge extension Jobs tab, or by `curl`ing
107
- `POST /api/jobs`. There is no YAML-on-disk format for Jobs (definitions
108
- live in sqlite).
109
-
110
- ## Lifecycle
111
-
112
- 1. Scheduler ticks every 60s. For each enabled job where `next_run_at`
113
- has elapsed:
114
- 2. Skip if a previous tick is still running (idempotent).
115
- 3. Advance `next_run_at = now + schedule_interval_minutes`.
116
- 4. Spawn a background tick:
117
- - Call the connector via the chat tool-dispatcher (handles
118
- `http` / `shell` / `browser` protocols uniformly).
119
- - Parse the response — find items via `items_path`.
120
- - For each item, compute `dedup_key = item[dedup_field]`,
121
- `INSERT OR IGNORE INTO job_seen`. If new: dispatch.
122
- - Update the `job_runs` row with counts + status.
123
-
124
- ## Browser-protocol caveat
125
-
126
- If `source_connector` is a `protocol: browser` connector (Mantis, GitLab,
127
- Teams, PMDB), the job tick can only fire **when the extension bridge is
128
- connected** — the tool needs a live tab and `chrome.scripting`. A tick
129
- with no extension fails with `connector ... failed: No extension
130
- connected to the bridge`. The run is marked errored; the next tick
131
- retries.
132
-
133
- For 24/7 background polling, prefer connectors with `protocol: http`
134
- (e.g. `github-api`) — those run server-side and don't need a browser.
135
-
136
- ## Tables
137
-
138
- ```
139
- jobs — definition (one row per job)
140
- job_runs — one row per tick (success / failure)
141
- job_seen — dedup keys per job (PRIMARY KEY (job_id, dedup_key))
142
- job_dispatches — one row per per-item dispatch attempt (link to pipeline run / chat session)
143
- ```
144
-
145
- All cascaded on `ON DELETE`.
@@ -1,115 +0,0 @@
1
- # Mantis → Bug Fix → MR pipeline
2
-
3
- End-to-end: a Mantis bug surfaces (via a Forge **Job** polling mantis.get_bug
4
- / mantis.search_bugs) → triggers a Pipeline → Pipeline checks out the right
5
- base branch in a worktree → headless Claude implements the fix → pipeline
6
- opens a GitLab MR via `glab` → pipeline pings the assignee + reporter on
7
- Teams with the MR URL.
8
-
9
- Mirrors the `gitlab-issue-fix-and-review` builtin but driven by Mantis
10
- content (description / priority / category / assignee / reporter), MR
11
- opens against an explicit `base_branch` you pass in from the Job (because
12
- Mantis doesn't carry milestones the way GitLab issues do).
13
-
14
- ## Builtin name
15
-
16
- `mantis-bug-fix-and-mr` — registered in Forge's built-in workflow set,
17
- visible in the Pipelines view's workflow dropdown and pickable from the
18
- extension Jobs tab when you wire a Pipeline-dispatch Job.
19
-
20
- ## Inputs (set by the Job's `input_template`)
21
-
22
- | Key | Required | Source |
23
- |---|---|---|
24
- | `bug_id` | yes | `{{item.id}}` |
25
- | `project` | yes | injected by `triggerPipeline` from the Job's project setting |
26
- | `base_branch` | **yes** | `{{item.product_version}}` mapped to a branch, OR a literal like `"release/25.4"` you write into the template |
27
- | `summary` | yes | `{{item.summary}}` |
28
- | `description` | yes | `{{item.description}}` |
29
- | `priority` | opt | `{{item.priority}}` |
30
- | `category` | opt | `{{item.category}}` |
31
- | `assignee` | opt | `{{item.assignee}}` — used as Teams chat name |
32
- | `reporter` | opt | `{{item.reporter}}` — used as Teams chat name |
33
- | `extra_context` | opt | literal hint text for Claude |
34
- | `mr_title_template` | opt | default `Fix Mantis #{bug_id}: {summary}` |
35
- | `mr_body_template` | opt | default closes-ref + Claude summary; vars `{bug_id} {summary} {description} {claude_summary}` |
36
- | `teams_message_template` | opt | default `🤖 Mantis #{bug_id} fixed — please review MR: {mr_url}\nBug: {summary}`; vars `{bug_id} {summary} {role} {mr_url}` |
37
-
38
- ## Nodes
39
-
40
- ```
41
- resolve parse git remote → HOST + PROJECT_PATH; check glab; require base_branch
42
- worktree-setup git worktree add -b fix/mantis-<id> .forge/worktrees/mantis-<id> origin/<base>
43
- fix-code headless Claude — reads bug context, edits in worktree, commits
44
- push-and-mr if any commits: push fix/mantis-<id>, glab mr create → MR_URL
45
- notify-teams curl /api/connector-tool teams.send_message twice (assignee + reporter)
46
- ```
47
-
48
- `fix-code` runs as a normal Claude task (not shell) so it can think, browse,
49
- edit, and commit. `notify-teams` short-circuits if `push-and-mr` couldn't
50
- create an MR (e.g. Claude made no changes).
51
-
52
- ## Talking to connectors from a pipeline
53
-
54
- `notify-teams` calls `POST http://127.0.0.1:8403/api/connector-tool` —
55
- a loopback-only endpoint that wraps `lib/chat/tool-dispatcher`. Body:
56
-
57
- ```json
58
- { "plugin_id": "teams", "tool": "send_message",
59
- "input": { "name": "Alice", "text": "MR: https://..." } }
60
- ```
61
-
62
- Returns the standard `{ content, is_error }` tool-result shape. Works for
63
- `browser` / `http` / `shell` protocol connectors. Browser-protocol calls
64
- need the extension bridge connected at pipeline runtime (Chrome open +
65
- extension signed in).
66
-
67
- ## Wiring it up from a Job
68
-
69
- 1. Confirm `glab` is installed + authed for the target host:
70
- `glab auth login --hostname <your-gitlab.example.com>`
71
- 2. Make sure the project has a git remote (`git remote get-url origin`) the
72
- `glab mr create` call can reach.
73
- 3. Forge extension → Jobs → + New job. Pick:
74
- - Connector / tool: `mantis` / `get_bug` (single bug) or `search_bugs` (a query)
75
- - dispatch: Pipeline → workflow `mantis-bug-fix-and-mr` → your project
76
- - `input_template` (auto-prefilled when you pick the workflow; map item
77
- keys to inputs, hardcode `base_branch` if Mantis doesn't carry it):
78
- ```json
79
- {
80
- "bug_id": "{{item.id}}",
81
- "summary": "{{item.summary}}",
82
- "description": "{{item.description}}",
83
- "priority": "{{item.priority}}",
84
- "category": "{{item.category}}",
85
- "assignee": "{{item.assignee}}",
86
- "reporter": "{{item.reporter}}",
87
- "base_branch": "release/25.4"
88
- }
89
- ```
90
- 4. Save → Run now (first tick is a backfill-no-op; click Reset dedup +
91
- Run now to force one actual dispatch for testing).
92
-
93
- ## Customising
94
-
95
- - Branch derivation rules (e.g. `target_version` → `release/<major.minor>`):
96
- edit the `resolve` node in your local copy at
97
- `~/.forge/data/flows/mantis-bug-fix-and-mr.yaml` (a copy will be created
98
- when you click Edit in the Pipelines view; once a local file exists it
99
- overrides the builtin).
100
- - MR title / body templates: set via the Job's `input_template` — no
101
- pipeline edit needed.
102
- - Teams routing: today it uses the Mantis username verbatim as the Teams
103
- chat name (substring match). For better mapping, post-process in
104
- `notify-teams` to convert username → real name via a lookup table or
105
- a second connector call.
106
-
107
- ## Troubleshooting
108
-
109
- | Symptom | Cause + fix |
110
- |---|---|
111
- | `ERROR: base_branch is required` | The Job's `input_template` didn't supply it — Mantis bugs don't always carry one. Hardcode it in the template or map from `product_version`. |
112
- | `NO_CHANGES — Claude did not commit` | Bug description was too thin or Claude couldn't find the affected code. Add hints via `extra_context` or open the worktree manually and iterate. |
113
- | `glab mr create` returns nothing | Token expired / target branch protected / source branch already has an open MR. The pipeline falls back to `glab mr view` to surface the existing URL — check Pipelines log. |
114
- | Teams send returns `No extension connected to the bridge` | The pipeline ran when Chrome / extension weren't online. Notification fails but the MR still landed; re-fire the `notify-teams` node manually or message the people yourself. |
115
- | Mantis username doesn't match any Teams chat | The fuzzy substring match in `teams.send_message` falls back to whatever the LLM-less DOM script can match. Add a lookup step before `notify-teams` to translate names. |