@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
@@ -9,27 +9,139 @@
9
9
 
10
10
  import {
11
11
  ensureSchema, getDueJobs, hasInflightRun, startRun, finishRun,
12
- markSeen, isSeen, recordDispatch, getJob, updateJob,
12
+ markSeen, isSeen, recordDispatch, getJob, updateJob, setNextRunAt,
13
13
  } from './store';
14
+ import { CronExpressionParser } from 'cron-parser';
15
+ import { ensureInstalledInProject } from '../skills';
14
16
  import type { Job, JobRunStatus, PipelineDispatchParams, ChatDispatchParams } from './types';
15
17
  import { dispatchTool } from '@/lib/chat/tool-dispatcher';
16
18
  import { dispatchToPipeline, dispatchToChat, dispatchToChatSummary } from './dispatcher';
17
19
  import { getDb } from '@/src/core/db/database';
18
20
  import { getDbPath } from '@/src/config';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join as joinPath } from 'node:path';
23
+ import { getDataDir } from '@/lib/dirs';
24
+
25
+ /** Reconcile stale pipeline_runs rows against the canonical JSON
26
+ * state on disk. Called before any count, so counts return real
27
+ * inflight numbers — not zombies left behind by missed
28
+ * syncRunStatus calls (process crash / ReferenceError swallowed by
29
+ * empty catch / etc).
30
+ *
31
+ * Rules:
32
+ * - Rows older than 30s in 'running'/'pending' get checked
33
+ * (younger ones might genuinely not have written first state).
34
+ * - JSON missing → mark DB row 'failed' (pipeline was cleaned up).
35
+ * - JSON in terminal state → sync DB row to that state.
36
+ * - JSON still running → leave the DB row.
37
+ *
38
+ * Costs one stat + small file read per stale row. Idempotent.
39
+ */
40
+ function reconcileStalePipelineRuns(): void {
41
+ try {
42
+ const db = getDb(getDbPath());
43
+ const stale = db.prepare(
44
+ `SELECT id, pipeline_id FROM pipeline_runs
45
+ WHERE status IN ('running', 'pending')
46
+ AND datetime(created_at) < datetime('now', '-30 seconds')`,
47
+ ).all() as { id: string; pipeline_id: string }[];
48
+
49
+ if (stale.length === 0) return;
50
+
51
+ const update = db.prepare(`UPDATE pipeline_runs SET status = ? WHERE id = ?`);
52
+ const pipelineDir = joinPath(getDataDir(), 'pipelines');
53
+
54
+ for (const row of stale) {
55
+ const file = joinPath(pipelineDir, `${row.pipeline_id}.json`);
56
+ if (!existsSync(file)) {
57
+ update.run('failed', row.id);
58
+ console.warn(`[scheduler] reconciled zombie pipeline_run ${row.id} → failed (json gone)`);
59
+ continue;
60
+ }
61
+ try {
62
+ const p = JSON.parse(readFileSync(file, 'utf8')) as { status?: string };
63
+ if (p.status && p.status !== 'running' && p.status !== 'pending') {
64
+ update.run(p.status, row.id);
65
+ console.warn(`[scheduler] reconciled pipeline_run ${row.id} → ${p.status}`);
66
+ }
67
+ } catch (e) {
68
+ console.warn(`[scheduler] failed to read ${file} during reconcile: ${(e as Error).message}`);
69
+ }
70
+ }
71
+ } catch (e) {
72
+ console.warn(`[scheduler] reconcileStalePipelineRuns failed: ${(e as Error).message}`);
73
+ }
74
+ }
19
75
 
20
- /** Count pipelines currently running or pending. Used as the global
21
- * concurrency budget paired with settings.maxConcurrentPipelines. */
76
+ /** Count pipelines currently running or pending — global. Used to
77
+ * enforce maxConcurrentPipelines. Reconciles stale rows first. */
22
78
  function countActivePipelines(): number {
79
+ reconcileStalePipelineRuns();
23
80
  try {
24
81
  const r = getDb(getDbPath()).prepare(
25
82
  `SELECT COUNT(*) AS n FROM pipeline_runs WHERE status IN ('running', 'pending')`,
26
83
  ).get() as { n: number } | undefined;
27
84
  return r?.n ?? 0;
28
- } catch {
85
+ } catch (e) {
86
+ console.warn(`[scheduler] countActivePipelines failed: ${(e as Error).message}`);
87
+ return 0;
88
+ }
89
+ }
90
+
91
+ /** Count THIS Job's previously-dispatched pipelines that are still
92
+ * running or pending. Used by sequential mode to gate the next tick.
93
+ * Reconciles stale rows first — without that, zombie 'running'
94
+ * rows from previous Forge crashes would block sequential Jobs forever. */
95
+ function countMyInflightPipelines(jobId: string): number {
96
+ reconcileStalePipelineRuns();
97
+ try {
98
+ const r = getDb(getDbPath()).prepare(`
99
+ SELECT COUNT(*) AS n
100
+ FROM pipeline_runs pr
101
+ WHERE pr.status IN ('running', 'pending')
102
+ AND pr.pipeline_id IN (
103
+ SELECT jd.dispatch_target_id
104
+ FROM job_dispatches jd
105
+ JOIN job_runs jr ON jr.id = jd.job_run_id
106
+ WHERE jr.job_id = ?
107
+ AND jd.dispatch_type = 'pipeline'
108
+ AND jd.created_at > datetime('now', '-1 day')
109
+ )
110
+ `).get(jobId) as { n: number } | undefined;
111
+ return r?.n ?? 0;
112
+ } catch (e) {
113
+ console.warn(`[scheduler] countMyInflightPipelines(${jobId}) failed: ${(e as Error).message}`);
29
114
  return 0;
30
115
  }
31
116
  }
32
117
 
118
+ /** "Is this Job busy right now?" — used by:
119
+ * 1. Manual fire endpoint to refuse double-clicks (return 409).
120
+ * 2. GET /api/jobs to render disabled state on Run / Force buttons.
121
+ *
122
+ * Busy ⇔
123
+ * - there's an inflight job_run (tick currently executing) OR
124
+ * - it's a sequential Job whose previously-dispatched pipeline
125
+ * is still running/pending.
126
+ *
127
+ * Reconciles stale rows before checking so zombies don't pin a
128
+ * Job as "busy" forever.
129
+ */
130
+ export function isJobBusy(jobId: string): { busy: boolean; reason: string } {
131
+ if (hasInflightRun(jobId)) {
132
+ return { busy: true, reason: 'a tick of this Job is currently executing' };
133
+ }
134
+ const job = getJob(jobId);
135
+ // Default-or-explicit sequential — check pipeline inflight.
136
+ if (job && (job as any).concurrency_mode !== 'parallel' && job.dispatch_type === 'pipeline') {
137
+ const n = countMyInflightPipelines(jobId);
138
+ if (n > 0) {
139
+ return { busy: true, reason: `${n} pipeline${n === 1 ? '' : 's'} from a prior run still active (sequential mode)` };
140
+ }
141
+ }
142
+ return { busy: false, reason: '' };
143
+ }
144
+
33
145
  /** Read settings.maxConcurrentPipelines (default 5, ceiling 20). */
34
146
  async function getMaxConcurrentPipelines(): Promise<number> {
35
147
  try {
@@ -72,7 +184,7 @@ async function tick(): Promise<void> {
72
184
  // Kick off the run; don't await — long connector calls / pipeline triggers
73
185
  // shouldn't block the scheduler loop.
74
186
  const { runId } = prepareRun(job, 'schedule');
75
- void executeRun(job, runId).catch((e) => {
187
+ void executeRun(job, runId, 'schedule').catch((e) => {
76
188
  console.error(`[jobs] runJob ${job.id} crashed`, e);
77
189
  });
78
190
  }
@@ -88,7 +200,6 @@ function toSqlIso(d: Date): string {
88
200
  * don't fire repeatedly when their schedule_at time is in the past.
89
201
  */
90
202
  function advanceSchedule(job: Job): void {
91
- const { setNextRunAt } = require('./store') as typeof import('./store');
92
203
  const now = Date.now();
93
204
 
94
205
  if (job.schedule_kind === 'manual') {
@@ -109,7 +220,6 @@ function advanceSchedule(job: Job): void {
109
220
 
110
221
  if (job.schedule_kind === 'cron' && job.schedule_cron) {
111
222
  try {
112
- const { CronExpressionParser } = require('cron-parser');
113
223
  const iter = CronExpressionParser.parse(job.schedule_cron, { currentDate: new Date(now) });
114
224
  const next = iter.next().toDate();
115
225
  setNextRunAt(job.id, toSqlIso(next));
@@ -134,7 +244,7 @@ function advanceSchedule(job: Job): void {
134
244
  */
135
245
  export async function runJob(jobOrId: Job | string, trigger: 'schedule' | 'manual'): Promise<string> {
136
246
  const { job, runId } = prepareRun(jobOrId, trigger);
137
- await executeRun(job, runId);
247
+ await executeRun(job, runId, trigger);
138
248
  return runId;
139
249
  }
140
250
 
@@ -158,7 +268,7 @@ export function prepareRun(jobOrId: Job | string, trigger: 'schedule' | 'manual'
158
268
  * we also mirror the high-level lines to console for live tailing via
159
269
  * `tail -f forge.log | grep [jobs]`.
160
270
  */
161
- export async function executeRun(job: Job, runId: string): Promise<void> {
271
+ export async function executeRun(job: Job, runId: string, trigger: 'schedule' | 'manual' = 'schedule'): Promise<void> {
162
272
  const t0 = Date.now();
163
273
  let itemsSeen = 0, itemsNew = 0, itemsDispatched = 0;
164
274
  let runError: string | null = null;
@@ -341,12 +451,74 @@ export async function executeRun(job: Job, runId: string): Promise<void> {
341
451
  // monopolizing all slots.
342
452
  // Why both: a single job with max_per_tick=10 can still go over if
343
453
  // there are already 15 pipelines from OTHER jobs in flight.
344
- const budget = (() => {
454
+ const concurrencyMode: 'parallel' | 'sequential' =
455
+ (job as any).concurrency_mode === 'parallel' ? 'parallel' : 'sequential';
456
+ const budget = concurrencyMode === 'sequential' ? 1 : (() => {
345
457
  const v = (job as any).max_per_tick;
346
458
  if (!Number.isFinite(v) || v == null) return 5;
347
459
  return Math.min(Math.max(Math.trunc(v), 1), 10);
348
460
  })();
349
461
  const globalCap = await getMaxConcurrentPipelines();
462
+
463
+ // Sequential gate: if any pipeline this Job previously dispatched
464
+ // is still running, defer the entire tick. Items stay un-dedup-
465
+ // marked so the next tick re-encounters them. This guarantees at
466
+ // most one pipeline from this Job runs at a time — solves
467
+ // GitLab/Mantis rate-limit + browser-tab race classes of bugs.
468
+ if (concurrencyMode === 'sequential' && job.dispatch_type === 'pipeline') {
469
+ // on_failure='stop': if the most recent dispatched pipeline ended
470
+ // in 'failed', halt the drain. User has to Force-run to resume.
471
+ // Default 'continue' just falls through to the regular gate check.
472
+ //
473
+ // Manual fires (Run now / Force run) bypass this check — they
474
+ // ARE the user's "resume after a failure" action; halting them
475
+ // would deadlock the Job.
476
+ const onFailure: 'continue' | 'stop' = (job as any).on_failure === 'stop' ? 'stop' : 'continue';
477
+ if (onFailure === 'stop' && trigger !== 'manual') {
478
+ try {
479
+ const recent = getDb(getDbPath()).prepare(`
480
+ SELECT pr.status FROM pipeline_runs pr
481
+ JOIN job_dispatches jd ON jd.dispatch_target_id = pr.pipeline_id
482
+ JOIN job_runs jr ON jr.id = jd.job_run_id
483
+ WHERE jr.job_id = ?
484
+ AND jd.dispatch_type = 'pipeline'
485
+ ORDER BY jd.created_at DESC LIMIT 1
486
+ `).get(job.id) as { status?: string } | undefined;
487
+ if (recent?.status === 'failed') {
488
+ logLine('warn', `on_failure=stop: previous pipeline FAILED — halting sequential drain. Clear with Force run.`);
489
+ try { setNextRunAt(job.id, null); } catch {}
490
+ persist({
491
+ status: 'ok',
492
+ notes: `Halted: on_failure=stop and previous pipeline failed. Force run to resume.`,
493
+ });
494
+ return;
495
+ }
496
+ } catch (e) {
497
+ logLine('warn', `on_failure check failed: ${(e as Error).message} — proceeding as if 'continue'`);
498
+ }
499
+ }
500
+
501
+ const myInflight = countMyInflightPipelines(job.id);
502
+ if (myInflight > 0) {
503
+ logLine('info', `sequential mode: ${myInflight} pipeline from this Job still running — deferring entire tick`);
504
+ // Drain-mode: schedule another tick in 60s so the queue keeps
505
+ // draining regardless of schedule_kind. Without this a manual
506
+ // Job whose advanceSchedule cleared next_run_at would never get
507
+ // re-picked-up, and the deferred items would sit forever.
508
+ try {
509
+ const nextDrain = new Date(Date.now() + 60_000);
510
+ setNextRunAt(job.id, toSqlIso(nextDrain));
511
+ } catch (e) {
512
+ logLine('warn', `sequential drain (gate): failed to set next_run_at: ${(e as Error).message}`);
513
+ }
514
+ persist({
515
+ status: 'ok',
516
+ notes: `Sequential: previous pipeline still inflight (${myInflight}). All ${itemsArr.length} item(s) deferred — will retry next tick.`,
517
+ });
518
+ return;
519
+ }
520
+ }
521
+
350
522
  let dispatchedThisTick = 0;
351
523
  let dedupHits = 0, missingKey = 0, deferred = 0;
352
524
  for (const [idx, item] of itemsArr.entries()) {
@@ -393,7 +565,6 @@ export async function executeRun(job: Job, runId: string): Promise<void> {
393
565
  if (targetProject) {
394
566
  for (const skillName of job.skills) {
395
567
  try {
396
- const { ensureInstalledInProject } = require('../skills');
397
568
  const r = await ensureInstalledInProject(skillName, targetProject);
398
569
  if (!r.installed) logLine('warn', `skill "${skillName}" not installable: ${r.reason}`);
399
570
  } catch (err) {
@@ -437,6 +608,24 @@ export async function executeRun(job: Job, runId: string): Promise<void> {
437
608
  const baseNote = note ? note + ' ' : '';
438
609
  note = `${baseNote}${deferred} item(s) deferred to next tick (per-Job budget ${budget} or global cap ${globalCap} reached).`;
439
610
  }
611
+
612
+ // Sequential drain mode: if this Job is sequential AND has deferred
613
+ // items waiting, force next_run_at to a short interval so the scheduler
614
+ // keeps picking it up until the batch is drained. Works for ANY
615
+ // schedule_kind — including 'manual' Jobs where the user did one
616
+ // Force run and expects all batched items to process automatically
617
+ // afterwards. Without this, manual + sequential = "one Force run
618
+ // dispatches exactly one item, stop" — which surprised the user.
619
+ if (deferred > 0 && concurrencyMode === 'sequential' && job.dispatch_type === 'pipeline') {
620
+ try {
621
+ const nextDrain = new Date(Date.now() + 60_000); // 60s — tick cycle
622
+ setNextRunAt(job.id, toSqlIso(nextDrain));
623
+ logLine('info', `sequential drain: ${deferred} item(s) still queued — next tick at ${nextDrain.toISOString()}`);
624
+ } catch (e) {
625
+ logLine('warn', `sequential drain: failed to set next_run_at: ${(e as Error).message}`);
626
+ }
627
+ }
628
+
440
629
  logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched, ${dedupHits} dedup hits` + (deferred ? `, ${deferred} deferred` : '') + (missingKey ? `, ${missingKey} missing-key` : ''));
441
630
  persist({ status: 'ok', notes: note });
442
631
  } catch (e) {
@@ -474,6 +663,21 @@ function pickPath(obj: unknown, path: string): unknown {
474
663
  }
475
664
 
476
665
  function pickDedupKey(item: unknown, field: string): string | null {
666
+ // `field` can be a single dot-path ("user.id") OR a colon-joined
667
+ // composite of multiple dot-paths ("iid:user_notes_count"). The
668
+ // composite form yields a stable signature for "this entity's
669
+ // current change state" — e.g. an MR id + its comment count, so a
670
+ // new comment bumps the signature and triggers a fresh dispatch.
671
+ // Any segment missing → null (caller skips item).
672
+ if (field.includes(':')) {
673
+ const parts: string[] = [];
674
+ for (const seg of field.split(':')) {
675
+ const v = pickPath(item, seg);
676
+ if (v == null) return null;
677
+ parts.push(typeof v === 'string' ? v : String(v));
678
+ }
679
+ return parts.join(':');
680
+ }
477
681
  const v = pickPath(item, field);
478
682
  if (v == null) return null;
479
683
  return typeof v === 'string' ? v : String(v);
package/lib/jobs/store.ts CHANGED
@@ -7,6 +7,7 @@ import { getDb } from '@/src/core/db/database';
7
7
  import { getDbPath } from '@/src/config';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { toIsoUTC } from '@/lib/iso-time';
10
+ import { CronExpressionParser } from 'cron-parser';
10
11
  import type {
11
12
  Job, JobRun, JobDispatch, CreateJobInput,
12
13
  JobRunStatus, JobRunTrigger, JobDispatchStatus,
@@ -46,6 +47,14 @@ export function ensureSchema(): void {
46
47
  to protect against catastrophic fan-out (e.g. mantis search
47
48
  returning 200 bugs and spawning 200 worktrees). */
48
49
  max_per_tick INTEGER NOT NULL DEFAULT 5,
50
+ /** 'sequential' (default) | 'sequential' — see Job.concurrency_mode
51
+ in types.ts. Sequential mode dispatches one pipeline at a
52
+ time and waits for it to reach a terminal state before
53
+ starting the next. */
54
+ concurrency_mode TEXT NOT NULL DEFAULT 'sequential',
55
+ /** 'continue' (default) | 'stop' — what to do when a dispatched
56
+ pipeline fails in sequential drain. See Job.on_failure. */
57
+ on_failure TEXT NOT NULL DEFAULT 'continue',
49
58
  last_run_at TEXT,
50
59
  next_run_at TEXT,
51
60
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -95,6 +104,8 @@ export function ensureSchema(): void {
95
104
  try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_at TEXT`); } catch {}
96
105
  try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_cron TEXT`); } catch {}
97
106
  try { db().exec(`ALTER TABLE jobs ADD COLUMN max_per_tick INTEGER NOT NULL DEFAULT 5`); } catch {}
107
+ try { db().exec(`ALTER TABLE jobs ADD COLUMN concurrency_mode TEXT NOT NULL DEFAULT 'sequential'`); } catch {}
108
+ try { db().exec(`ALTER TABLE jobs ADD COLUMN on_failure TEXT NOT NULL DEFAULT 'continue'`); } catch {}
98
109
  ensured = true;
99
110
  }
100
111
 
@@ -118,6 +129,8 @@ function rowToJob(r: any): Job {
118
129
  schedule_at: toIsoUTC(r.schedule_at),
119
130
  schedule_cron: r.schedule_cron || null,
120
131
  max_per_tick: typeof r.max_per_tick === 'number' ? r.max_per_tick : 5,
132
+ concurrency_mode: r.concurrency_mode === 'parallel' ? 'parallel' : 'sequential',
133
+ on_failure: r.on_failure === 'stop' ? 'stop' : 'continue',
121
134
  last_run_at: toIsoUTC(r.last_run_at),
122
135
  next_run_at: toIsoUTC(r.next_run_at),
123
136
  created_at: toIsoUTC(r.created_at) || r.created_at,
@@ -193,8 +206,9 @@ export function createJob(input: CreateJobInput): Job {
193
206
  source_connector, source_tool, source_input,
194
207
  items_path, dedup_field,
195
208
  dispatch_type, dispatch_params, skills,
196
- schedule_kind, schedule_at, schedule_cron, max_per_tick)
197
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
+ schedule_kind, schedule_at, schedule_cron, max_per_tick,
210
+ concurrency_mode, on_failure)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
198
212
  `).run(
199
213
  id,
200
214
  input.name,
@@ -212,6 +226,8 @@ export function createJob(input: CreateJobInput): Job {
212
226
  input.schedule_at || null,
213
227
  input.schedule_cron || null,
214
228
  clampMaxPerTick(input.max_per_tick),
229
+ input.concurrency_mode === 'parallel' ? 'parallel' : 'sequential',
230
+ input.on_failure === 'stop' ? 'stop' : 'continue',
215
231
  );
216
232
 
217
233
  // Backfill guard: if mark_existing_as_seen is true (default), we don't pre-seed
@@ -232,7 +248,6 @@ export function createJob(input: CreateJobInput): Job {
232
248
  if (!Number.isNaN(t.getTime())) setNextRunAt(id, t.toISOString().replace('T', ' ').slice(0, 19));
233
249
  } else if (input.schedule_kind === 'cron' && input.schedule_cron) {
234
250
  try {
235
- const { CronExpressionParser } = require('cron-parser');
236
251
  const iter = CronExpressionParser.parse(input.schedule_cron, { currentDate: new Date() });
237
252
  const next = iter.next().toDate();
238
253
  setNextRunAt(id, next.toISOString().replace('T', ' ').slice(0, 19));
@@ -252,6 +267,8 @@ export function updateJob(id: string, patch: Partial<{
252
267
  schedule_at: string | null;
253
268
  schedule_cron: string | null;
254
269
  max_per_tick: number;
270
+ concurrency_mode: 'parallel' | 'sequential';
271
+ on_failure: 'continue' | 'stop';
255
272
  }>): boolean {
256
273
  ensureSchema();
257
274
  const sets: string[] = []; const vals: any[] = [];
@@ -270,6 +287,14 @@ export function updateJob(id: string, patch: Partial<{
270
287
  if (patch.schedule_at !== undefined) { sets.push('schedule_at = ?'); vals.push(patch.schedule_at); }
271
288
  if (patch.schedule_cron !== undefined) { sets.push('schedule_cron = ?'); vals.push(patch.schedule_cron); }
272
289
  if (patch.max_per_tick !== undefined) { sets.push('max_per_tick = ?'); vals.push(clampMaxPerTick(patch.max_per_tick)); }
290
+ if (patch.concurrency_mode !== undefined) {
291
+ sets.push('concurrency_mode = ?');
292
+ vals.push(patch.concurrency_mode === 'parallel' ? 'parallel' : 'sequential');
293
+ }
294
+ if (patch.on_failure !== undefined) {
295
+ sets.push('on_failure = ?');
296
+ vals.push(patch.on_failure === 'stop' ? 'stop' : 'continue');
297
+ }
273
298
  if (sets.length === 0) return false;
274
299
  sets.push("updated_at = datetime('now')");
275
300
  vals.push(id);
@@ -396,3 +421,54 @@ export function listDispatches(runId: string): JobDispatch[] {
396
421
  const rows = db().prepare('SELECT * FROM job_dispatches WHERE job_run_id = ? ORDER BY created_at ASC').all(runId) as any[];
397
422
  return rows.map(rowToDispatch);
398
423
  }
424
+
425
+ /** Recent pipelines this Job has dispatched, decorated with live
426
+ * pipeline_runs status. Used by the Job row to show what's running,
427
+ * what's done, what failed — without making the user navigate to the
428
+ * Pipeline view for each. Capped at N per call to keep the UI fast. */
429
+ export interface JobDispatchedPipeline {
430
+ dispatch_id: string;
431
+ job_run_id: string;
432
+ item_key: string;
433
+ item_preview: string | null;
434
+ pipeline_id: string;
435
+ pipeline_status: string; // 'running' | 'pending' | 'done' | 'failed' | 'cancelled' | 'unknown'
436
+ workflow_name: string | null;
437
+ dispatched_at: string;
438
+ }
439
+
440
+ export function listJobDispatchedPipelines(jobId: string, limit = 20): JobDispatchedPipeline[] {
441
+ ensureSchema();
442
+ const rows = db().prepare(`
443
+ SELECT jd.id AS dispatch_id, jd.job_run_id, jd.item_key, jd.item_preview,
444
+ jd.dispatch_target_id AS pipeline_id, jd.created_at AS dispatched_at,
445
+ pr.status AS pipeline_status, pr.workflow_name AS workflow_name
446
+ FROM job_dispatches jd
447
+ JOIN job_runs jr ON jr.id = jd.job_run_id
448
+ LEFT JOIN pipeline_runs pr ON pr.pipeline_id = jd.dispatch_target_id
449
+ WHERE jr.job_id = ?
450
+ AND jd.dispatch_type = 'pipeline'
451
+ ORDER BY jd.created_at DESC
452
+ LIMIT ?
453
+ `).all(jobId, limit) as any[];
454
+ return rows.map((r) => ({
455
+ dispatch_id: r.dispatch_id,
456
+ job_run_id: r.job_run_id,
457
+ item_key: r.item_key,
458
+ item_preview: r.item_preview,
459
+ pipeline_id: r.pipeline_id,
460
+ pipeline_status: r.pipeline_status || 'unknown',
461
+ workflow_name: r.workflow_name,
462
+ dispatched_at: toIsoUTC(r.dispatched_at) || r.dispatched_at,
463
+ }));
464
+ }
465
+
466
+ /** Stop the sequential drain for this Job — clears next_run_at so the
467
+ * scheduler won't pick it up automatically. Does NOT cancel
468
+ * pipelines that are already running (caller can do that separately
469
+ * on the Pipeline view). Returns true if anything was changed. */
470
+ export function cancelJobDrain(jobId: string): boolean {
471
+ ensureSchema();
472
+ const r = db().prepare(`UPDATE jobs SET next_run_at = NULL WHERE id = ?`).run(jobId);
473
+ return r.changes > 0;
474
+ }
package/lib/jobs/types.ts CHANGED
@@ -90,6 +90,31 @@ export interface Job {
90
90
  * over to the next tick. Protects disk/RAM from fan-out blow-up. */
91
91
  max_per_tick: number;
92
92
 
93
+ /** How this Job paces pipeline dispatch:
94
+ *
95
+ * 'sequential' (default) — at most ONE pipeline from this Job runs
96
+ * at a time. Each tick checks whether the previously
97
+ * dispatched pipeline has reached a terminal state;
98
+ * if not, the entire tick is skipped (item stays
99
+ * un-dedup-marked, rolls over to next tick). Avoids
100
+ * hammering downstream systems (GitLab rate limits,
101
+ * Mantis browser-tab races, resource contention).
102
+ * This is the safer default.
103
+ *
104
+ * 'parallel' — each tick dispatches up to max_per_tick items
105
+ * concurrently. Items still hit the global pipeline
106
+ * cap, but no per-Job throttle beyond that. Use
107
+ * only when downstream is known to tolerate burst. */
108
+ concurrency_mode: 'parallel' | 'sequential';
109
+
110
+ /** What to do when an item's pipeline ends in 'failed' state:
111
+ * 'continue' (default) — proceed to next item; each item is
112
+ * independent so one failure doesn't poison the batch.
113
+ * 'stop' — halt drain (clears next_run_at). User must Force-
114
+ * run again to resume. Use when a failure likely means
115
+ * something systemic broke (auth lost, repo gone). */
116
+ on_failure: 'continue' | 'stop';
117
+
93
118
  last_run_at: string | null;
94
119
  next_run_at: string | null;
95
120
  created_at: string;
@@ -153,6 +178,12 @@ export interface CreateJobInput {
153
178
  /** Per-tick dispatch budget (default 5, capped 1-10 in scheduler). */
154
179
  max_per_tick?: number;
155
180
 
181
+ /** 'parallel' (default) | 'sequential'. See Job.concurrency_mode. */
182
+ concurrency_mode?: 'parallel' | 'sequential';
183
+
184
+ /** 'continue' (default) | 'stop'. See Job.on_failure. */
185
+ on_failure?: 'continue' | 'stop';
186
+
156
187
  /** Default true: first tick records existing items as seen without dispatching. */
157
188
  mark_existing_as_seen?: boolean;
158
189
  }
package/lib/logger.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
+ import { getDataDir } from './dirs';
9
10
 
10
11
  // Use globalThis to prevent double-init across forge-server.mjs and init.ts
11
12
  const loggerKey = Symbol.for('forge-logger-init');
@@ -21,7 +22,6 @@ export function initLogger() {
21
22
  let logFile: string | null = null;
22
23
  if (!isProduction) {
23
24
  try {
24
- const { getDataDir } = require('./dirs');
25
25
  const dataDir = getDataDir();
26
26
  if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
27
27
  logFile = join(dataDir, 'forge.log');
package/lib/notify.ts CHANGED
@@ -6,11 +6,20 @@ import { loadSettings } from './settings';
6
6
  import { addNotification } from './notifications';
7
7
  import type { Task } from '@/src/types';
8
8
 
9
+ /** Look up the shared pipelineTaskIds Set via globalThis Symbol.
10
+ * pipeline.ts populates it on module init; using the Symbol avoids
11
+ * a require() that would fire ReferenceError on every task complete
12
+ * under concurrent loads (each completion hits notify, 5 pipelines
13
+ * × 5 nodes = 25 races per Job run). */
14
+ function isPipelineTask(taskId: string): boolean {
15
+ const key = Symbol.for('mw-pipeline-task-ids');
16
+ const set = (globalThis as any)[key] as Set<string> | undefined;
17
+ return set ? set.has(taskId) : false;
18
+ }
19
+
9
20
  export async function notifyTaskComplete(task: Task) {
10
21
  // Skip pipeline tasks
11
- let isPipeline = false;
12
- try { const { pipelineTaskIds } = require('./pipeline'); isPipeline = pipelineTaskIds.has(task.id); } catch {}
13
- if (isPipeline) return;
22
+ if (isPipelineTask(task.id)) return;
14
23
 
15
24
  const cost = task.costUSD != null ? `$${task.costUSD.toFixed(4)}` : 'unknown';
16
25
  const duration = task.startedAt && task.completedAt
@@ -44,9 +53,7 @@ export async function notifyTaskComplete(task: Task) {
44
53
 
45
54
  export async function notifyTaskFailed(task: Task) {
46
55
  // Skip pipeline tasks
47
- let isPipeline = false;
48
- try { const { pipelineTaskIds } = require('./pipeline'); isPipeline = pipelineTaskIds.has(task.id); } catch {}
49
- if (isPipeline) return;
56
+ if (isPipelineTask(task.id)) return;
50
57
 
51
58
  // In-app notification (always)
52
59
  try {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Pipeline scratch-dir garbage collector.
3
+ *
4
+ * Scans every project's `.forge/worktrees/` for `pipeline-<id>/` dirs
5
+ * created by the `{{run.tmp_dir}}` mechanism in lib/pipeline.ts. Compares
6
+ * each dir's pipeline state (status + completedAt) against the retention
7
+ * settings and rm -rf's expired ones.
8
+ *
9
+ * Called from:
10
+ * - lib/init.ts setInterval (default every 6h, settings.pipelineTmpGcIntervalHours)
11
+ * - cli/mw.ts `forge pipeline gc` (manual / dry-run)
12
+ *
13
+ * Retention rules (settings):
14
+ * - done → wiped immediately when pipeline.status flips (in pipeline.ts checkPipelineCompletion).
15
+ * GC here only catches done dirs left over from older builds.
16
+ * - failed → kept pipelineTmpKeepFailedDays days, then swept.
17
+ * - cancelled → kept pipelineTmpKeepCancelledDays days, then swept.
18
+ * - running / started → never touched.
19
+ * - orphan (no pipeline state file) → swept after 7d based on mtime.
20
+ */
21
+
22
+ import { readdirSync, statSync, rmSync, existsSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { scanProjects } from './projects';
25
+ import { getPipeline } from './pipeline';
26
+ import { loadSettings } from './settings';
27
+
28
+ export interface GcResult {
29
+ scanned: number;
30
+ removed: { path: string; reason: string }[];
31
+ kept: { path: string; reason: string }[];
32
+ }
33
+
34
+ const ORPHAN_KEEP_MS = 7 * 86400_000;
35
+
36
+ export function gcPipelineTmp(opts: { dryRun?: boolean } = {}): GcResult {
37
+ const settings = loadSettings();
38
+ const failedKeepMs = Math.max(0, (settings.pipelineTmpKeepFailedDays ?? 3)) * 86400_000;
39
+ const cancelledKeepMs = Math.max(0, (settings.pipelineTmpKeepCancelledDays ?? 3)) * 86400_000;
40
+ const cleanDoneNow = settings.pipelineTmpCleanDoneImmediate !== false;
41
+ const now = Date.now();
42
+
43
+ const removed: GcResult['removed'] = [];
44
+ const kept: GcResult['kept'] = [];
45
+ let scanned = 0;
46
+
47
+ for (const proj of scanProjects()) {
48
+ const wtDir = join(proj.path, '.forge', 'worktrees');
49
+ if (!existsSync(wtDir)) continue;
50
+ let entries: string[];
51
+ try { entries = readdirSync(wtDir); } catch { continue; }
52
+
53
+ for (const entry of entries) {
54
+ if (!entry.startsWith('pipeline-')) continue;
55
+ const pipeId = entry.slice('pipeline-'.length);
56
+ const fullPath = join(wtDir, entry);
57
+ scanned++;
58
+
59
+ const pipeline = getPipeline(pipeId);
60
+
61
+ // Orphan: pipeline state gone. Use mtime as best signal.
62
+ if (!pipeline) {
63
+ let mtimeMs: number;
64
+ try { mtimeMs = statSync(fullPath).mtimeMs; } catch { continue; }
65
+ if (now - mtimeMs > ORPHAN_KEEP_MS) {
66
+ if (!opts.dryRun) {
67
+ try { rmSync(fullPath, { recursive: true, force: true }); } catch {}
68
+ }
69
+ removed.push({ path: fullPath, reason: `orphan (>${Math.round(ORPHAN_KEEP_MS / 86400_000)}d)` });
70
+ } else {
71
+ kept.push({ path: fullPath, reason: 'orphan, still fresh' });
72
+ }
73
+ continue;
74
+ }
75
+
76
+ const completedAt = pipeline.completedAt ? Date.parse(pipeline.completedAt) : null;
77
+ let shouldRemove = false;
78
+ let reason = '';
79
+
80
+ if (pipeline.status === 'done' && cleanDoneNow) {
81
+ shouldRemove = true;
82
+ reason = 'done (immediate cleanup enabled)';
83
+ } else if (pipeline.status === 'failed' && completedAt && now - completedAt > failedKeepMs) {
84
+ shouldRemove = true;
85
+ reason = `failed > ${settings.pipelineTmpKeepFailedDays}d`;
86
+ } else if (pipeline.status === 'cancelled' && completedAt && now - completedAt > cancelledKeepMs) {
87
+ shouldRemove = true;
88
+ reason = `cancelled > ${settings.pipelineTmpKeepCancelledDays}d`;
89
+ }
90
+
91
+ if (shouldRemove) {
92
+ if (!opts.dryRun) {
93
+ try { rmSync(fullPath, { recursive: true, force: true }); } catch (e) {
94
+ console.warn(`[pipeline-gc] rm ${fullPath} failed: ${(e as Error).message}`);
95
+ }
96
+ }
97
+ removed.push({ path: fullPath, reason });
98
+ } else {
99
+ kept.push({ path: fullPath, reason: `status=${pipeline.status}` });
100
+ }
101
+ }
102
+ }
103
+
104
+ return { scanned, removed, kept };
105
+ }