@aion0/forge 0.8.2 → 0.8.4
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 +5 -24
- package/app/api/jobs/route.ts +3 -0
- package/lib/chat/protocols/http.ts +20 -3
- package/lib/help-docs/17-connectors.md +1 -1
- package/lib/jobs/scheduler.ts +45 -3
- package/lib/jobs/store.ts +42 -2
- package/lib/jobs/types.ts +20 -0
- package/lib/pipeline.ts +2 -2
- package/package.json +2 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,31 +1,12 @@
|
|
|
1
|
-
# Forge v0.8.
|
|
1
|
+
# Forge v0.8.4
|
|
2
2
|
|
|
3
3
|
Released: 2026-05-20
|
|
4
4
|
|
|
5
|
-
## Changes since v0.8.
|
|
5
|
+
## Changes since v0.8.3
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
9
|
-
- fix(
|
|
10
|
-
- feat(jobs): Job.skills + thread through to claude --append-system-prompt
|
|
11
|
-
- fix(connectors): friendlier error when browser-probe handler missing
|
|
12
|
-
- fix(pipeline): allow retry on running nodes + reap orphans on boot
|
|
13
|
-
- feat(connectors): browser-side test probe via extension bridge
|
|
14
|
-
- feat(connectors): manifest-driven Test button
|
|
15
|
-
- docs(connectors): 21-build-connector.md + AI routing for authoring
|
|
16
|
-
- feat(connectors): Upload button + drag-and-drop in marketplace
|
|
17
|
-
- feat(connectors): install-local API — accept YAML or zip
|
|
18
|
-
- refactor(connectors): move marketplace from Settings to SkillsPanel tab
|
|
19
|
-
- fix(connectors): surface fetch root cause + show installed in marketplace
|
|
20
|
-
- docs(connectors): rewrite 17-connectors.md for marketplace model
|
|
21
|
-
- feat(connectors): drop builtin yamls + purge connector code from plugin/
|
|
22
|
-
- feat(connectors): one-shot migration from plugin-configs.json
|
|
23
|
-
- feat(connectors): Settings Marketplace panel
|
|
24
|
-
- feat(connectors): marketplace API /api/connectors/marketplace
|
|
25
|
-
- feat(connectors): route /api/connectors + chat through new registry
|
|
26
|
-
- feat(connectors): sync — pull registry + manifests from forge-connectors
|
|
27
|
-
- feat(connectors): registry — load manifests from <dataDir>/connectors/
|
|
28
|
-
- feat(connectors): extract independent types in lib/connectors/
|
|
8
|
+
- fix(http-protocol): apply manifest parameter defaults before template expansion
|
|
9
|
+
- fix(http-protocol): drop unsubstituted {args.*} query params
|
|
29
10
|
|
|
30
11
|
|
|
31
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.
|
|
12
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.3...v0.8.4
|
package/app/api/jobs/route.ts
CHANGED
|
@@ -31,6 +31,9 @@ export async function POST(req: Request) {
|
|
|
31
31
|
dispatch_type: body.dispatch_type,
|
|
32
32
|
dispatch_params: body.dispatch_params,
|
|
33
33
|
skills: Array.isArray(body.skills) ? body.skills.map((s: unknown) => String(s)) : [],
|
|
34
|
+
schedule_kind: ['once', 'cron', 'manual'].includes(body.schedule_kind) ? body.schedule_kind : 'period',
|
|
35
|
+
schedule_at: body.schedule_at ? String(body.schedule_at) : null,
|
|
36
|
+
schedule_cron: body.schedule_cron ? String(body.schedule_cron) : null,
|
|
34
37
|
mark_existing_as_seen: body.mark_existing_as_seen !== false,
|
|
35
38
|
});
|
|
36
39
|
return NextResponse.json({ job });
|
|
@@ -51,6 +51,10 @@ function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Re
|
|
|
51
51
|
const url = new URL(base);
|
|
52
52
|
for (const [k, raw] of Object.entries(spec.query)) {
|
|
53
53
|
const v = expandAllTokens(String(raw), settings, args);
|
|
54
|
+
// Drop query params whose value didn't substitute (caller didn't pass
|
|
55
|
+
// that optional arg). Leaving `{args.foo}` in the URL would send it
|
|
56
|
+
// verbatim and many APIs return 400 on unknown values.
|
|
57
|
+
if (/\{(args|settings)\./.test(v) || v === '') continue;
|
|
54
58
|
url.searchParams.append(k, v);
|
|
55
59
|
}
|
|
56
60
|
return url.toString();
|
|
@@ -90,9 +94,22 @@ export async function runHttp({ tool, settings, args }: HttpProtocolArgs): Promi
|
|
|
90
94
|
const method = (spec.method || 'GET').toUpperCase();
|
|
91
95
|
const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
// Apply parameter defaults from the manifest so templates like
|
|
98
|
+
// {args.per_page} can resolve when the LLM omits the field. LLMs are
|
|
99
|
+
// inconsistent about including defaults in tool calls; merging them
|
|
100
|
+
// here is closer to how users expect a schema `default:` to behave.
|
|
101
|
+
const argsWithDefaults: Record<string, any> = { ...args };
|
|
102
|
+
if (tool.parameters) {
|
|
103
|
+
for (const [name, schema] of Object.entries(tool.parameters)) {
|
|
104
|
+
if (argsWithDefaults[name] === undefined && schema && (schema as any).default !== undefined) {
|
|
105
|
+
argsWithDefaults[name] = (schema as any).default;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const url = buildUrl(spec, settings, argsWithDefaults);
|
|
111
|
+
const headers = buildHeaders(spec, settings, argsWithDefaults);
|
|
112
|
+
const { body, contentType } = buildBody(spec, settings, argsWithDefaults);
|
|
96
113
|
if (body != null && contentType && !headers.has('content-type')) headers.set('content-type', contentType);
|
|
97
114
|
|
|
98
115
|
const controller = new AbortController();
|
|
@@ -364,7 +364,7 @@ restores everything without re-entering credentials.
|
|
|
364
364
|
| Symptom | Cause + fix |
|
|
365
365
|
|---|---|
|
|
366
366
|
| Marketplace is empty | Initial sync failed (offline / bad URL). Click **Refresh** in Settings → Connectors. |
|
|
367
|
-
| `Sync error: … self-signed certificate in certificate chain` | Corporate TLS-interception proxy (
|
|
367
|
+
| `Sync error: … self-signed certificate in certificate chain` | Corporate TLS-interception proxy (Zscaler, Palo Alto, etc.). Node doesn't trust the proxy's root CA by default. Export `NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem` before starting Forge, or run `forge server restart` with that env var set. macOS users can extract the bundle from Keychain Access → System Roots. |
|
|
368
368
|
| `Sync error: … ENOTFOUND` | DNS can't resolve `raw.githubusercontent.com` — VPN / firewall blocking. Point `connectorsRepoUrl` at a private mirror, or sync manifests manually into `<dataDir>/connectors/<id>/manifest.yaml`. |
|
|
369
369
|
| Installed connector missing from chat | Manifest deleted but config row remains. Re-install from the marketplace. |
|
|
370
370
|
| "Update available" badge stuck | Network failure on the refresh. Re-sync via **Refresh**. |
|
package/lib/jobs/scheduler.ts
CHANGED
|
@@ -50,11 +50,53 @@ async function tick(): Promise<void> {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function toSqlIso(d: Date): string {
|
|
54
|
+
return d.toISOString().replace('T', ' ').slice(0, 19);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute and store the job's next_run_at based on schedule_kind.
|
|
59
|
+
* For 'once' jobs we also auto-disable after the firing tick so they
|
|
60
|
+
* don't fire repeatedly when their schedule_at time is in the past.
|
|
61
|
+
*/
|
|
53
62
|
function advanceSchedule(job: Job): void {
|
|
54
|
-
const next = new Date(Date.now() + Math.max(1, job.schedule_interval_minutes) * 60_000);
|
|
55
|
-
// setNextRunAt also bumps last_run_at — call updateJob via raw SQL for clarity.
|
|
56
63
|
const { setNextRunAt } = require('./store') as typeof import('./store');
|
|
57
|
-
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
|
|
66
|
+
if (job.schedule_kind === 'manual') {
|
|
67
|
+
// Manual jobs are filtered out of getDueJobs and should never get
|
|
68
|
+
// here. Belt-and-suspenders: clear any stray next_run_at.
|
|
69
|
+
setNextRunAt(job.id, null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (job.schedule_kind === 'once') {
|
|
74
|
+
// One-shot: this tick is the fire. Disable for future, no next run.
|
|
75
|
+
setNextRunAt(job.id, null);
|
|
76
|
+
try { updateJob(job.id, { enabled: false }); } catch (e) {
|
|
77
|
+
console.warn(`[jobs] failed to auto-disable one-shot ${job.id}:`, e);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (job.schedule_kind === 'cron' && job.schedule_cron) {
|
|
83
|
+
try {
|
|
84
|
+
const { CronExpressionParser } = require('cron-parser');
|
|
85
|
+
const iter = CronExpressionParser.parse(job.schedule_cron, { currentDate: new Date(now) });
|
|
86
|
+
const next = iter.next().toDate();
|
|
87
|
+
setNextRunAt(job.id, toSqlIso(next));
|
|
88
|
+
return;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.warn(`[jobs] cron parse failed for ${job.id} (expr "${job.schedule_cron}"):`, (e as Error).message);
|
|
91
|
+
// Fall through to period-style backoff so a broken cron doesn't
|
|
92
|
+
// tight-loop the tick.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Default / 'period' path.
|
|
97
|
+
const minutes = Math.max(1, job.schedule_interval_minutes || 30);
|
|
98
|
+
const next = new Date(now + minutes * 60_000);
|
|
99
|
+
setNextRunAt(job.id, toSqlIso(next));
|
|
58
100
|
}
|
|
59
101
|
|
|
60
102
|
/**
|
package/lib/jobs/store.ts
CHANGED
|
@@ -34,6 +34,12 @@ export function ensureSchema(): void {
|
|
|
34
34
|
/** JSON array of skill names (from forge-skills registry).
|
|
35
35
|
Composed into the task system prompt at dispatch time. */
|
|
36
36
|
skills TEXT NOT NULL DEFAULT '[]',
|
|
37
|
+
/** 'period' (default — fire every schedule_interval_minutes),
|
|
38
|
+
'once' (fire once at schedule_at, then auto-disable), or
|
|
39
|
+
'cron' (fire on each cron tick per schedule_cron). */
|
|
40
|
+
schedule_kind TEXT NOT NULL DEFAULT 'period',
|
|
41
|
+
schedule_at TEXT,
|
|
42
|
+
schedule_cron TEXT,
|
|
37
43
|
last_run_at TEXT,
|
|
38
44
|
next_run_at TEXT,
|
|
39
45
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
@@ -79,6 +85,9 @@ export function ensureSchema(): void {
|
|
|
79
85
|
try { db().exec(`ALTER TABLE job_runs ADD COLUMN log TEXT`); } catch {}
|
|
80
86
|
// Migration for already-existing jobs table.
|
|
81
87
|
try { db().exec(`ALTER TABLE jobs ADD COLUMN skills TEXT NOT NULL DEFAULT '[]'`); } catch {}
|
|
88
|
+
try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_kind TEXT NOT NULL DEFAULT 'period'`); } catch {}
|
|
89
|
+
try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_at TEXT`); } catch {}
|
|
90
|
+
try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_cron TEXT`); } catch {}
|
|
82
91
|
ensured = true;
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -98,6 +107,9 @@ function rowToJob(r: any): Job {
|
|
|
98
107
|
dispatch_type: r.dispatch_type,
|
|
99
108
|
dispatch_params: safeParse(r.dispatch_params, {}) as DispatchParams,
|
|
100
109
|
skills: safeParse(r.skills, []) as string[],
|
|
110
|
+
schedule_kind: (r.schedule_kind as 'period' | 'once' | 'cron' | 'manual') || 'period',
|
|
111
|
+
schedule_at: toIsoUTC(r.schedule_at),
|
|
112
|
+
schedule_cron: r.schedule_cron || null,
|
|
101
113
|
last_run_at: toIsoUTC(r.last_run_at),
|
|
102
114
|
next_run_at: toIsoUTC(r.next_run_at),
|
|
103
115
|
created_at: toIsoUTC(r.created_at) || r.created_at,
|
|
@@ -162,8 +174,9 @@ export function createJob(input: CreateJobInput): Job {
|
|
|
162
174
|
INSERT INTO jobs (id, name, enabled, schedule_interval_minutes,
|
|
163
175
|
source_connector, source_tool, source_input,
|
|
164
176
|
items_path, dedup_field,
|
|
165
|
-
dispatch_type, dispatch_params, skills
|
|
166
|
-
|
|
177
|
+
dispatch_type, dispatch_params, skills,
|
|
178
|
+
schedule_kind, schedule_at, schedule_cron)
|
|
179
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
167
180
|
`).run(
|
|
168
181
|
id,
|
|
169
182
|
input.name,
|
|
@@ -177,6 +190,9 @@ export function createJob(input: CreateJobInput): Job {
|
|
|
177
190
|
input.dispatch_type,
|
|
178
191
|
JSON.stringify(input.dispatch_params),
|
|
179
192
|
JSON.stringify(Array.isArray(input.skills) ? input.skills : []),
|
|
193
|
+
input.schedule_kind || 'period',
|
|
194
|
+
input.schedule_at || null,
|
|
195
|
+
input.schedule_cron || null,
|
|
180
196
|
);
|
|
181
197
|
|
|
182
198
|
// Backfill guard: if mark_existing_as_seen is true (default), we don't pre-seed
|
|
@@ -189,6 +205,21 @@ export function createJob(input: CreateJobInput): Job {
|
|
|
189
205
|
db().prepare('UPDATE jobs SET source_input = ? WHERE id = ?').run(JSON.stringify(inputWithFlag), id);
|
|
190
206
|
}
|
|
191
207
|
|
|
208
|
+
// Seed next_run_at so the scheduler picks the job up at the right
|
|
209
|
+
// moment instead of immediately (which getDueJobs would otherwise
|
|
210
|
+
// do for any NULL next_run_at).
|
|
211
|
+
if (input.schedule_kind === 'once' && input.schedule_at) {
|
|
212
|
+
const t = new Date(input.schedule_at);
|
|
213
|
+
if (!Number.isNaN(t.getTime())) setNextRunAt(id, t.toISOString().replace('T', ' ').slice(0, 19));
|
|
214
|
+
} else if (input.schedule_kind === 'cron' && input.schedule_cron) {
|
|
215
|
+
try {
|
|
216
|
+
const { CronExpressionParser } = require('cron-parser');
|
|
217
|
+
const iter = CronExpressionParser.parse(input.schedule_cron, { currentDate: new Date() });
|
|
218
|
+
const next = iter.next().toDate();
|
|
219
|
+
setNextRunAt(id, next.toISOString().replace('T', ' ').slice(0, 19));
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
|
|
192
223
|
return getJob(id)!;
|
|
193
224
|
}
|
|
194
225
|
|
|
@@ -198,6 +229,9 @@ export function updateJob(id: string, patch: Partial<{
|
|
|
198
229
|
items_path: string; dedup_field: string;
|
|
199
230
|
dispatch_type: 'pipeline' | 'chat'; dispatch_params: DispatchParams;
|
|
200
231
|
skills: string[];
|
|
232
|
+
schedule_kind: 'period' | 'once' | 'cron' | 'manual';
|
|
233
|
+
schedule_at: string | null;
|
|
234
|
+
schedule_cron: string | null;
|
|
201
235
|
}>): boolean {
|
|
202
236
|
ensureSchema();
|
|
203
237
|
const sets: string[] = []; const vals: any[] = [];
|
|
@@ -212,6 +246,9 @@ export function updateJob(id: string, patch: Partial<{
|
|
|
212
246
|
if (patch.dispatch_type !== undefined) { sets.push('dispatch_type = ?'); vals.push(patch.dispatch_type); }
|
|
213
247
|
if (patch.dispatch_params !== undefined) { sets.push('dispatch_params = ?'); vals.push(JSON.stringify(patch.dispatch_params)); }
|
|
214
248
|
if (patch.skills !== undefined) { sets.push('skills = ?'); vals.push(JSON.stringify(Array.isArray(patch.skills) ? patch.skills : [])); }
|
|
249
|
+
if (patch.schedule_kind !== undefined) { sets.push('schedule_kind = ?'); vals.push(patch.schedule_kind); }
|
|
250
|
+
if (patch.schedule_at !== undefined) { sets.push('schedule_at = ?'); vals.push(patch.schedule_at); }
|
|
251
|
+
if (patch.schedule_cron !== undefined) { sets.push('schedule_cron = ?'); vals.push(patch.schedule_cron); }
|
|
215
252
|
if (sets.length === 0) return false;
|
|
216
253
|
sets.push("updated_at = datetime('now')");
|
|
217
254
|
vals.push(id);
|
|
@@ -233,9 +270,12 @@ export function setNextRunAt(id: string, nextRunAt: string | null): void {
|
|
|
233
270
|
/** Jobs due to run: enabled AND (next_run_at IS NULL OR next_run_at <= now). */
|
|
234
271
|
export function getDueJobs(): Job[] {
|
|
235
272
|
ensureSchema();
|
|
273
|
+
// Exclude schedule_kind='manual' — those only run when explicitly
|
|
274
|
+
// fired via /api/jobs/[id]/fire, never on the scheduler tick.
|
|
236
275
|
const rows = db().prepare(`
|
|
237
276
|
SELECT * FROM jobs
|
|
238
277
|
WHERE enabled = 1
|
|
278
|
+
AND schedule_kind != 'manual'
|
|
239
279
|
AND (next_run_at IS NULL OR next_run_at <= datetime('now'))
|
|
240
280
|
ORDER BY (next_run_at IS NULL) DESC, next_run_at ASC
|
|
241
281
|
`).all() as any[];
|
package/lib/jobs/types.ts
CHANGED
|
@@ -71,6 +71,19 @@ export interface Job {
|
|
|
71
71
|
*/
|
|
72
72
|
skills: string[];
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Trigger model:
|
|
76
|
+
* 'period' — fire every `schedule_interval_minutes`
|
|
77
|
+
* 'once' — fire once at `schedule_at` (ISO), then auto-disable
|
|
78
|
+
* 'cron' — fire on each match of `schedule_cron` (5-field cron, server local TZ)
|
|
79
|
+
* 'manual' — never auto-fire; only the Fire / Force button (POST /api/jobs/[id]/fire) starts a run
|
|
80
|
+
*/
|
|
81
|
+
schedule_kind: 'period' | 'once' | 'cron' | 'manual';
|
|
82
|
+
/** ISO timestamp; only used when schedule_kind === 'once'. */
|
|
83
|
+
schedule_at: string | null;
|
|
84
|
+
/** Cron expression (5 fields); only used when schedule_kind === 'cron'. */
|
|
85
|
+
schedule_cron: string | null;
|
|
86
|
+
|
|
74
87
|
last_run_at: string | null;
|
|
75
88
|
next_run_at: string | null;
|
|
76
89
|
created_at: string;
|
|
@@ -124,6 +137,13 @@ export interface CreateJobInput {
|
|
|
124
137
|
/** Skill names to forward into the dispatched task. See Job.skills. */
|
|
125
138
|
skills?: string[];
|
|
126
139
|
|
|
140
|
+
/** Default 'period'. */
|
|
141
|
+
schedule_kind?: 'period' | 'once' | 'cron' | 'manual';
|
|
142
|
+
/** ISO timestamp, required when schedule_kind === 'once'. */
|
|
143
|
+
schedule_at?: string | null;
|
|
144
|
+
/** Cron expression, required when schedule_kind === 'cron'. */
|
|
145
|
+
schedule_cron?: string | null;
|
|
146
|
+
|
|
127
147
|
/** Default true: first tick records existing items as seen without dispatching. */
|
|
128
148
|
mark_existing_as_seen?: boolean;
|
|
129
149
|
}
|
package/lib/pipeline.ts
CHANGED
|
@@ -567,8 +567,8 @@ nodes:
|
|
|
567
567
|
DESCRIPTION_B64 full bug description (base64; often the most
|
|
568
568
|
important field — repro steps live here)
|
|
569
569
|
ADDITIONAL_INFO_B64 "Additional Information" custom field —
|
|
570
|
-
|
|
571
|
-
|
|
570
|
+
often holds stack traces, env details, log
|
|
571
|
+
snippets. Decode + read.
|
|
572
572
|
NOTES_B64 all comments concatenated, with author + date
|
|
573
573
|
headers ([author @ date]). Use to see what
|
|
574
574
|
QA + reporter already discussed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aion0/forge",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"adm-zip": "^0.5.17",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"better-sqlite3": "^12.6.2",
|
|
47
|
+
"cron-parser": "^5.5.0",
|
|
47
48
|
"esbuild": "^0.27.3",
|
|
48
49
|
"next": "^16.2.1",
|
|
49
50
|
"next-auth": "5.0.0-beta.30",
|