@aion0/forge 0.8.2 → 0.8.3

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 CHANGED
@@ -1,31 +1,12 @@
1
- # Forge v0.8.2
1
+ # Forge v0.8.3
2
2
 
3
3
  Released: 2026-05-20
4
4
 
5
- ## Changes since v0.8.1
5
+ ## Changes since v0.8.2
6
6
 
7
7
  ### Other
8
- - feat(skills): local skill upload (.md / .zip), parallel to connector install-local
9
- - fix(pipeline): auto-sync gitlab connector PAT into glab auth + env
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
+ - feat(jobs): add 'manual' schedule kind Fire-button-only
9
+ - feat(jobs): one-shot + cron schedule kinds alongside period
29
10
 
30
11
 
31
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.1...v0.8.2
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.2...v0.8.3
@@ -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 });
@@ -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 (Fortinet, Zscaler, Palo Alto, ). 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. |
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**. |
@@ -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
- setNextRunAt(job.id, next.toISOString().replace('T', ' ').slice(0, 19));
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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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
- FortiNAC bug reports usually put stack traces,
571
- env details, log snippets here. Decode + read.
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.2",
3
+ "version": "0.8.3",
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",