@aion0/forge 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +60 -7
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +116 -7
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- package/lib/help-docs/22-recipes.md +0 -124
package/lib/jobs/scheduler.ts
CHANGED
|
@@ -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
|
|
21
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|