@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
|
@@ -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 */
|
package/lib/task-manager.ts
CHANGED
|
@@ -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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
275
|
-
|
|
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.
|
|
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",
|
package/lib/help-docs/19-jobs.md
DELETED
|
@@ -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. |
|