@aion0/forge 0.9.1 → 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 -5
- 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 +106 -0
- 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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute Schedule's transient state fields for API + UI.
|
|
3
|
+
* Stays in sync with the spec's `activeState()`:
|
|
4
|
+
*
|
|
5
|
+
* if !enabled → 'paused'
|
|
6
|
+
* else if inflight_count>0 → 'running'
|
|
7
|
+
* else if last=='failed' → 'last_failed'
|
|
8
|
+
* else → 'idle'
|
|
9
|
+
*
|
|
10
|
+
* NOT stored — every read recomputes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DecoratedSchedule, Schedule, ActiveState } from './types';
|
|
14
|
+
import { countInflightForSchedule, lastTerminalStatusForSchedule } from './store';
|
|
15
|
+
|
|
16
|
+
export function computeActiveState(args: {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
inflight_count: number;
|
|
19
|
+
last_status: string | null;
|
|
20
|
+
}): ActiveState {
|
|
21
|
+
if (!args.enabled) return 'paused';
|
|
22
|
+
if (args.inflight_count > 0) return 'running';
|
|
23
|
+
if (args.last_status === 'failed') return 'last_failed';
|
|
24
|
+
return 'idle';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decorateSchedule(s: Schedule): DecoratedSchedule {
|
|
28
|
+
const inflight_count = countInflightForSchedule(s.id);
|
|
29
|
+
const last_status = lastTerminalStatusForSchedule(s.id);
|
|
30
|
+
const active_state = computeActiveState({
|
|
31
|
+
enabled: s.enabled,
|
|
32
|
+
inflight_count,
|
|
33
|
+
last_status,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
...s,
|
|
37
|
+
inflight_count,
|
|
38
|
+
last_status,
|
|
39
|
+
active_state,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules SQLite store (V2). Two tables:
|
|
3
|
+
*
|
|
4
|
+
* schedules — config (body_kind/body_ref/input, action_*, schedule_*)
|
|
5
|
+
* schedule_runs — every dispatched body (single source of truth
|
|
6
|
+
* for "what did this schedule fire and how did action fare")
|
|
7
|
+
*
|
|
8
|
+
* V1 table names were `pipeline_schedules` / `pipeline_schedule_runs`
|
|
9
|
+
* with hard-coded pipeline_name / pipeline_id columns. ensureSchema()
|
|
10
|
+
* detects the old layout on first run and migrates in place.
|
|
11
|
+
*
|
|
12
|
+
* Schedule's `active_state` is NOT stored — computed from enabled +
|
|
13
|
+
* inflight count + last_status (see state.ts).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { join as joinPath } from 'node:path';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { getDb } from '@/src/core/db/database';
|
|
20
|
+
import { getDbPath } from '@/src/config';
|
|
21
|
+
import { getDataDir } from '@/lib/dirs';
|
|
22
|
+
import { toIsoUTC } from '@/lib/iso-time';
|
|
23
|
+
import type {
|
|
24
|
+
Schedule,
|
|
25
|
+
ScheduleRun,
|
|
26
|
+
ScheduleKind,
|
|
27
|
+
ScheduleBodyKind,
|
|
28
|
+
ScheduleActionKind,
|
|
29
|
+
ScheduleActionStatus,
|
|
30
|
+
ScheduleRunStatus,
|
|
31
|
+
ScheduleRunTrigger,
|
|
32
|
+
CreateScheduleInput,
|
|
33
|
+
UpdateScheduleInput,
|
|
34
|
+
} from './types';
|
|
35
|
+
|
|
36
|
+
function db() { return getDb(getDbPath()); }
|
|
37
|
+
|
|
38
|
+
let ensured = false;
|
|
39
|
+
export function ensureSchema(): void {
|
|
40
|
+
if (ensured) return;
|
|
41
|
+
const conn = db();
|
|
42
|
+
|
|
43
|
+
// V2 schema — create new tables if absent.
|
|
44
|
+
conn.exec(`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
name TEXT NOT NULL,
|
|
48
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
49
|
+
|
|
50
|
+
body_kind TEXT NOT NULL DEFAULT 'pipeline',
|
|
51
|
+
body_ref TEXT NOT NULL,
|
|
52
|
+
input TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
skills TEXT NOT NULL DEFAULT '[]',
|
|
54
|
+
|
|
55
|
+
action_kind TEXT NOT NULL DEFAULT 'none',
|
|
56
|
+
action_config TEXT NOT NULL DEFAULT '{}',
|
|
57
|
+
action_skip_on_empty INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
|
|
59
|
+
schedule_kind TEXT NOT NULL DEFAULT 'period',
|
|
60
|
+
schedule_interval_minutes INTEGER NOT NULL DEFAULT 30,
|
|
61
|
+
schedule_at TEXT,
|
|
62
|
+
schedule_cron TEXT,
|
|
63
|
+
next_run_at TEXT,
|
|
64
|
+
last_run_at TEXT,
|
|
65
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
66
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_schedules_due
|
|
69
|
+
ON schedules(enabled, next_run_at);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS schedule_runs (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
schedule_id TEXT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
|
|
74
|
+
target_id TEXT NOT NULL,
|
|
75
|
+
trigger TEXT NOT NULL,
|
|
76
|
+
status TEXT NOT NULL DEFAULT 'started',
|
|
77
|
+
body_output TEXT,
|
|
78
|
+
action_status TEXT,
|
|
79
|
+
action_error TEXT,
|
|
80
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
81
|
+
finished_at TEXT,
|
|
82
|
+
error TEXT
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_sr_schedule ON schedule_runs(schedule_id, started_at DESC);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_sr_target ON schedule_runs(target_id);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_sr_started ON schedule_runs(status) WHERE status = 'started';
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
// ── V1 → V2 migration ──
|
|
90
|
+
// Detect legacy tables and copy data. We don't drop the legacy tables
|
|
91
|
+
// here — leave them for the user to inspect; a follow-up migration
|
|
92
|
+
// can drop them once V2 is verified working.
|
|
93
|
+
migrateLegacyTables(conn);
|
|
94
|
+
|
|
95
|
+
// Idempotent column adds for V2 tables created before the column existed.
|
|
96
|
+
try { conn.exec(`ALTER TABLE schedules ADD COLUMN skills TEXT NOT NULL DEFAULT '[]'`); } catch {}
|
|
97
|
+
|
|
98
|
+
ensured = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function migrateLegacyTables(conn: ReturnType<typeof db>): void {
|
|
102
|
+
try {
|
|
103
|
+
const legacyExists = conn.prepare(
|
|
104
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name='pipeline_schedules'`,
|
|
105
|
+
).get();
|
|
106
|
+
if (!legacyExists) return;
|
|
107
|
+
|
|
108
|
+
// Already migrated? (Heuristic: if schedules has any rows, assume done.)
|
|
109
|
+
// In that case still rename the legacy table out of the way so the
|
|
110
|
+
// next start can't resurrect deleted rows by re-running the copy.
|
|
111
|
+
const v2Count = conn.prepare(`SELECT COUNT(*) AS n FROM schedules`).get() as { n: number };
|
|
112
|
+
if (v2Count.n > 0) {
|
|
113
|
+
try {
|
|
114
|
+
conn.exec(`ALTER TABLE pipeline_schedules RENAME TO pipeline_schedules_v1_backup`);
|
|
115
|
+
const r = conn.prepare(
|
|
116
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name='pipeline_schedule_runs'`,
|
|
117
|
+
).get();
|
|
118
|
+
if (r) conn.exec(`ALTER TABLE pipeline_schedule_runs RENAME TO pipeline_schedule_runs_v1_backup`);
|
|
119
|
+
console.log('[schedules] migration already done — renamed leftover v1 tables to *_v1_backup');
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.warn(`[schedules] leftover v1 rename failed: ${(e as Error).message}`);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const legacyRows = conn.prepare(`SELECT * FROM pipeline_schedules`).all() as any[];
|
|
127
|
+
if (legacyRows.length === 0) {
|
|
128
|
+
console.log('[schedules] legacy pipeline_schedules table exists but empty — no migration needed');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(`[schedules] migrating ${legacyRows.length} legacy schedule(s) from pipeline_schedules → schedules`);
|
|
133
|
+
const insertSched = conn.prepare(`
|
|
134
|
+
INSERT INTO schedules (
|
|
135
|
+
id, name, enabled,
|
|
136
|
+
body_kind, body_ref, input,
|
|
137
|
+
action_kind, action_config, action_skip_on_empty,
|
|
138
|
+
schedule_kind, schedule_interval_minutes, schedule_at, schedule_cron,
|
|
139
|
+
next_run_at, last_run_at, created_at, updated_at
|
|
140
|
+
) VALUES (?, ?, ?, 'pipeline', ?, ?, 'none', '{}', 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
141
|
+
`);
|
|
142
|
+
const migrateOne = conn.transaction((rows: any[]) => {
|
|
143
|
+
for (const r of rows) {
|
|
144
|
+
insertSched.run(
|
|
145
|
+
r.id, r.name, r.enabled ? 1 : 0,
|
|
146
|
+
r.pipeline_name, r.input || '{}',
|
|
147
|
+
r.schedule_kind || 'period',
|
|
148
|
+
r.schedule_interval_minutes ?? 30,
|
|
149
|
+
r.schedule_at || null, r.schedule_cron || null,
|
|
150
|
+
r.next_run_at || null, r.last_run_at || null,
|
|
151
|
+
r.created_at, r.updated_at,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
migrateOne(legacyRows);
|
|
156
|
+
|
|
157
|
+
// Migrate legacy runs too.
|
|
158
|
+
const legacyRunsExists = conn.prepare(
|
|
159
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name='pipeline_schedule_runs'`,
|
|
160
|
+
).get();
|
|
161
|
+
if (legacyRunsExists) {
|
|
162
|
+
const legacyRuns = conn.prepare(`SELECT * FROM pipeline_schedule_runs`).all() as any[];
|
|
163
|
+
if (legacyRuns.length > 0) {
|
|
164
|
+
const insertRun = conn.prepare(`
|
|
165
|
+
INSERT INTO schedule_runs (
|
|
166
|
+
id, schedule_id, target_id, trigger, status,
|
|
167
|
+
body_output, action_status, action_error,
|
|
168
|
+
started_at, finished_at, error
|
|
169
|
+
) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)
|
|
170
|
+
`);
|
|
171
|
+
const migrateRuns = conn.transaction((rows: any[]) => {
|
|
172
|
+
for (const r of rows) {
|
|
173
|
+
insertRun.run(
|
|
174
|
+
r.id, r.schedule_id, r.pipeline_id, r.trigger || 'schedule',
|
|
175
|
+
r.status || 'done', r.started_at, r.finished_at || null, r.error || null,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
migrateRuns(legacyRuns);
|
|
180
|
+
console.log(`[schedules] migrated ${legacyRuns.length} legacy run(s)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Rename the v1 tables out of the way so a future user-driven delete
|
|
185
|
+
// (which empties `schedules` to 0 rows) doesn't make the v2Count==0
|
|
186
|
+
// heuristic above re-trigger and resurrect deleted schedules. We
|
|
187
|
+
// rename rather than drop so the data stays recoverable if something
|
|
188
|
+
// about the v2 schema turns out to be wrong.
|
|
189
|
+
conn.exec(`ALTER TABLE pipeline_schedules RENAME TO pipeline_schedules_v1_backup`);
|
|
190
|
+
if (legacyRunsExists) {
|
|
191
|
+
conn.exec(`ALTER TABLE pipeline_schedule_runs RENAME TO pipeline_schedule_runs_v1_backup`);
|
|
192
|
+
}
|
|
193
|
+
console.log('[schedules] renamed v1 tables to *_v1_backup');
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.warn(`[schedules] legacy migration failed: ${(e as Error).message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Row mappers ──────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function rowToSchedule(r: any): Schedule {
|
|
202
|
+
return {
|
|
203
|
+
id: r.id,
|
|
204
|
+
name: r.name,
|
|
205
|
+
enabled: !!r.enabled,
|
|
206
|
+
body_kind: (r.body_kind as ScheduleBodyKind) || 'pipeline',
|
|
207
|
+
body_ref: r.body_ref,
|
|
208
|
+
input: safeJsonObject(r.input),
|
|
209
|
+
skills: safeJsonStringArray(r.skills),
|
|
210
|
+
action_kind: (r.action_kind as ScheduleActionKind) || 'none',
|
|
211
|
+
action_config: safeJsonObject(r.action_config),
|
|
212
|
+
action_skip_on_empty: !!r.action_skip_on_empty,
|
|
213
|
+
schedule_kind: (r.schedule_kind as ScheduleKind) || 'period',
|
|
214
|
+
schedule_interval_minutes: typeof r.schedule_interval_minutes === 'number'
|
|
215
|
+
? r.schedule_interval_minutes : 30,
|
|
216
|
+
schedule_at: toIsoUTC(r.schedule_at),
|
|
217
|
+
schedule_cron: r.schedule_cron || null,
|
|
218
|
+
next_run_at: toIsoUTC(r.next_run_at),
|
|
219
|
+
last_run_at: toIsoUTC(r.last_run_at),
|
|
220
|
+
created_at: toIsoUTC(r.created_at) || r.created_at,
|
|
221
|
+
updated_at: toIsoUTC(r.updated_at) || r.updated_at,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function rowToRun(r: any): ScheduleRun {
|
|
226
|
+
return {
|
|
227
|
+
id: r.id,
|
|
228
|
+
schedule_id: r.schedule_id,
|
|
229
|
+
target_id: r.target_id,
|
|
230
|
+
trigger: (r.trigger as ScheduleRunTrigger) || 'schedule',
|
|
231
|
+
status: (r.status as ScheduleRunStatus) || 'started',
|
|
232
|
+
body_output: r.body_output ?? null,
|
|
233
|
+
action_status: (r.action_status as ScheduleActionStatus) ?? null,
|
|
234
|
+
action_error: r.action_error ?? null,
|
|
235
|
+
started_at: toIsoUTC(r.started_at) || r.started_at,
|
|
236
|
+
finished_at: toIsoUTC(r.finished_at),
|
|
237
|
+
error: r.error || null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function safeJsonObject(raw: unknown): Record<string, unknown> {
|
|
242
|
+
if (!raw || typeof raw !== 'string') return {};
|
|
243
|
+
try {
|
|
244
|
+
const v = JSON.parse(raw);
|
|
245
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
246
|
+
} catch { return {}; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function safeJsonStringArray(raw: unknown): string[] {
|
|
250
|
+
if (!raw || typeof raw !== 'string') return [];
|
|
251
|
+
try {
|
|
252
|
+
const v = JSON.parse(raw);
|
|
253
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
254
|
+
} catch { return []; }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Schedule CRUD ────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export function listSchedules(): Schedule[] {
|
|
260
|
+
ensureSchema();
|
|
261
|
+
const rows = db().prepare('SELECT * FROM schedules ORDER BY created_at DESC').all() as any[];
|
|
262
|
+
return rows.map(rowToSchedule);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function getSchedule(id: string): Schedule | null {
|
|
266
|
+
ensureSchema();
|
|
267
|
+
const r = db().prepare('SELECT * FROM schedules WHERE id = ?').get(id) as any;
|
|
268
|
+
return r ? rowToSchedule(r) : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createSchedule(input: CreateScheduleInput): Schedule {
|
|
272
|
+
ensureSchema();
|
|
273
|
+
const id = `sch_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
|
|
274
|
+
db().prepare(`
|
|
275
|
+
INSERT INTO schedules (
|
|
276
|
+
id, name, enabled,
|
|
277
|
+
body_kind, body_ref, input, skills,
|
|
278
|
+
action_kind, action_config, action_skip_on_empty,
|
|
279
|
+
schedule_kind, schedule_interval_minutes, schedule_at, schedule_cron
|
|
280
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
281
|
+
`).run(
|
|
282
|
+
id,
|
|
283
|
+
input.name,
|
|
284
|
+
input.enabled === false ? 0 : 1,
|
|
285
|
+
input.body_kind || 'pipeline',
|
|
286
|
+
input.body_ref,
|
|
287
|
+
JSON.stringify(input.input ?? {}),
|
|
288
|
+
JSON.stringify(Array.isArray(input.skills) ? input.skills.filter((s) => typeof s === 'string') : []),
|
|
289
|
+
input.action_kind || 'none',
|
|
290
|
+
JSON.stringify(input.action_config ?? {}),
|
|
291
|
+
input.action_skip_on_empty ? 1 : 0,
|
|
292
|
+
input.schedule_kind || 'period',
|
|
293
|
+
input.schedule_interval_minutes ?? 30,
|
|
294
|
+
input.schedule_at || null,
|
|
295
|
+
input.schedule_cron || null,
|
|
296
|
+
);
|
|
297
|
+
// Schedule the first run so scheduler picks it up.
|
|
298
|
+
// For 'manual', leave next_run_at NULL.
|
|
299
|
+
if ((input.schedule_kind || 'period') !== 'manual') {
|
|
300
|
+
seedNextRunAt(id);
|
|
301
|
+
}
|
|
302
|
+
return getSchedule(id)!;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function updateSchedule(id: string, patch: UpdateScheduleInput): boolean {
|
|
306
|
+
ensureSchema();
|
|
307
|
+
const sets: string[] = [];
|
|
308
|
+
const vals: any[] = [];
|
|
309
|
+
if (patch.name !== undefined) { sets.push('name = ?'); vals.push(patch.name); }
|
|
310
|
+
if (patch.enabled !== undefined) { sets.push('enabled = ?'); vals.push(patch.enabled ? 1 : 0); }
|
|
311
|
+
if (patch.input !== undefined) { sets.push('input = ?'); vals.push(JSON.stringify(patch.input)); }
|
|
312
|
+
if (patch.skills !== undefined) { sets.push('skills = ?'); vals.push(JSON.stringify(Array.isArray(patch.skills) ? patch.skills.filter((s) => typeof s === 'string') : [])); }
|
|
313
|
+
if (patch.action_kind !== undefined) { sets.push('action_kind = ?'); vals.push(patch.action_kind); }
|
|
314
|
+
if (patch.action_config !== undefined) { sets.push('action_config = ?'); vals.push(JSON.stringify(patch.action_config)); }
|
|
315
|
+
if (patch.action_skip_on_empty !== undefined) { sets.push('action_skip_on_empty = ?'); vals.push(patch.action_skip_on_empty ? 1 : 0); }
|
|
316
|
+
if (patch.schedule_kind !== undefined) { sets.push('schedule_kind = ?'); vals.push(patch.schedule_kind); }
|
|
317
|
+
if (patch.schedule_interval_minutes !== undefined) { sets.push('schedule_interval_minutes = ?'); vals.push(patch.schedule_interval_minutes); }
|
|
318
|
+
if (patch.schedule_at !== undefined) { sets.push('schedule_at = ?'); vals.push(patch.schedule_at); }
|
|
319
|
+
if (patch.schedule_cron !== undefined) { sets.push('schedule_cron = ?'); vals.push(patch.schedule_cron); }
|
|
320
|
+
if (sets.length === 0) return false;
|
|
321
|
+
sets.push("updated_at = datetime('now')");
|
|
322
|
+
vals.push(id);
|
|
323
|
+
const r = db().prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
324
|
+
|
|
325
|
+
// If the schedule shape changed in a way that affects next_run_at,
|
|
326
|
+
// re-seed (e.g. user switched manual → period).
|
|
327
|
+
if (patch.schedule_kind !== undefined || patch.schedule_interval_minutes !== undefined ||
|
|
328
|
+
patch.schedule_at !== undefined || patch.schedule_cron !== undefined || patch.enabled !== undefined) {
|
|
329
|
+
const s = getSchedule(id);
|
|
330
|
+
if (s && s.enabled && s.schedule_kind !== 'manual') {
|
|
331
|
+
seedNextRunAt(id);
|
|
332
|
+
} else if (s && (!s.enabled || s.schedule_kind === 'manual')) {
|
|
333
|
+
setNextRunAt(id, null);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return r.changes > 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function deleteSchedule(id: string): boolean {
|
|
340
|
+
ensureSchema();
|
|
341
|
+
const r = db().prepare('DELETE FROM schedules WHERE id = ?').run(id);
|
|
342
|
+
return r.changes > 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Schedule tick helpers ────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Set next_run_at = now for a freshly-created schedule so it fires
|
|
348
|
+
* on the next tick instead of waiting up to schedule_interval. */
|
|
349
|
+
export function seedNextRunAt(id: string): void {
|
|
350
|
+
ensureSchema();
|
|
351
|
+
db().prepare(`UPDATE schedules SET next_run_at = datetime('now') WHERE id = ?`).run(id);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function setNextRunAt(id: string, iso: string | null): void {
|
|
355
|
+
ensureSchema();
|
|
356
|
+
db().prepare(`UPDATE schedules SET next_run_at = ? WHERE id = ?`).run(iso, id);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function setLastRunAt(id: string, iso: string): void {
|
|
360
|
+
ensureSchema();
|
|
361
|
+
db().prepare(`UPDATE schedules SET last_run_at = ? WHERE id = ?`).run(iso, id);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Schedules whose enabled=1 AND (next_run_at IS NULL OR next_run_at <= now). */
|
|
365
|
+
export function listDueSchedules(): Schedule[] {
|
|
366
|
+
ensureSchema();
|
|
367
|
+
const rows = db().prepare(`
|
|
368
|
+
SELECT * FROM schedules
|
|
369
|
+
WHERE enabled = 1
|
|
370
|
+
AND (next_run_at IS NULL OR datetime(next_run_at) <= datetime('now'))
|
|
371
|
+
AND schedule_kind != 'manual'
|
|
372
|
+
ORDER BY (next_run_at IS NULL) DESC, next_run_at ASC
|
|
373
|
+
`).all() as any[];
|
|
374
|
+
return rows.map(rowToSchedule);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── Schedule runs ────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export function insertScheduleRun(args: {
|
|
380
|
+
schedule_id: string;
|
|
381
|
+
target_id: string;
|
|
382
|
+
trigger: ScheduleRunTrigger;
|
|
383
|
+
}): ScheduleRun {
|
|
384
|
+
ensureSchema();
|
|
385
|
+
const id = `sr_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
|
|
386
|
+
db().prepare(`
|
|
387
|
+
INSERT INTO schedule_runs (id, schedule_id, target_id, trigger, status)
|
|
388
|
+
VALUES (?, ?, ?, ?, 'started')
|
|
389
|
+
`).run(id, args.schedule_id, args.target_id, args.trigger);
|
|
390
|
+
return getScheduleRun(id)!;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function getScheduleRun(id: string): ScheduleRun | null {
|
|
394
|
+
ensureSchema();
|
|
395
|
+
const r = db().prepare('SELECT * FROM schedule_runs WHERE id = ?').get(id) as any;
|
|
396
|
+
return r ? rowToRun(r) : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function listScheduleRuns(scheduleId: string, limit = 20): ScheduleRun[] {
|
|
400
|
+
ensureSchema();
|
|
401
|
+
const rows = db().prepare(
|
|
402
|
+
'SELECT * FROM schedule_runs WHERE schedule_id = ? ORDER BY started_at DESC LIMIT ?',
|
|
403
|
+
).all(scheduleId, limit) as any[];
|
|
404
|
+
return rows.map(rowToRun);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function countInflightForSchedule(scheduleId: string): number {
|
|
408
|
+
ensureSchema();
|
|
409
|
+
const r = db().prepare(
|
|
410
|
+
`SELECT COUNT(*) AS n FROM schedule_runs
|
|
411
|
+
WHERE schedule_id = ? AND status = 'started'`,
|
|
412
|
+
).get(scheduleId) as { n: number } | undefined;
|
|
413
|
+
return r?.n ?? 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function listInflightForSchedule(scheduleId: string): ScheduleRun[] {
|
|
417
|
+
ensureSchema();
|
|
418
|
+
const rows = db().prepare(
|
|
419
|
+
`SELECT * FROM schedule_runs
|
|
420
|
+
WHERE schedule_id = ? AND status = 'started'
|
|
421
|
+
ORDER BY started_at DESC`,
|
|
422
|
+
).all(scheduleId) as any[];
|
|
423
|
+
return rows.map(rowToRun);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function lastTerminalStatusForSchedule(scheduleId: string): ScheduleRunStatus | null {
|
|
427
|
+
ensureSchema();
|
|
428
|
+
const r = db().prepare(`
|
|
429
|
+
SELECT status FROM schedule_runs
|
|
430
|
+
WHERE schedule_id = ?
|
|
431
|
+
AND status IN ('done', 'failed', 'cancelled')
|
|
432
|
+
ORDER BY finished_at DESC NULLS LAST
|
|
433
|
+
LIMIT 1
|
|
434
|
+
`).get(scheduleId) as { status: ScheduleRunStatus } | undefined;
|
|
435
|
+
return r?.status ?? null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Called by pipeline-scheduler.syncRunStatus (and future skill /
|
|
439
|
+
* connector_tool body completion) to settle the run's body status. */
|
|
440
|
+
export function updateScheduleRunStatus(args: {
|
|
441
|
+
target_id: string;
|
|
442
|
+
status: ScheduleRunStatus;
|
|
443
|
+
error?: string | null;
|
|
444
|
+
}): void {
|
|
445
|
+
ensureSchema();
|
|
446
|
+
db().prepare(`
|
|
447
|
+
UPDATE schedule_runs
|
|
448
|
+
SET status = ?, finished_at = datetime('now'), error = ?
|
|
449
|
+
WHERE target_id = ?
|
|
450
|
+
AND status = 'started'
|
|
451
|
+
`).run(args.status, args.error ?? null, args.target_id);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Store body's captured output. Called by scheduler.executeSchedule
|
|
455
|
+
* after the body finishes — feeds into action. */
|
|
456
|
+
export function setScheduleRunBodyOutput(runId: string, output: string | null): void {
|
|
457
|
+
ensureSchema();
|
|
458
|
+
db().prepare(
|
|
459
|
+
`UPDATE schedule_runs SET body_output = ? WHERE id = ?`,
|
|
460
|
+
).run(output, runId);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Look up the live (status='started') schedule_run by target_id and write
|
|
464
|
+
* body_output. Used by pipeline-scheduler / skill task settler — they know
|
|
465
|
+
* the target id, not the run id. No-op if no live row matches. */
|
|
466
|
+
export function setScheduleRunBodyOutputByTarget(targetId: string, output: string | null): void {
|
|
467
|
+
ensureSchema();
|
|
468
|
+
const r = db().prepare(
|
|
469
|
+
`SELECT id FROM schedule_runs WHERE target_id = ? AND status = 'started' LIMIT 1`,
|
|
470
|
+
).get(targetId) as { id: string } | undefined;
|
|
471
|
+
if (r) setScheduleRunBodyOutput(r.id, output);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Settle the action half of a schedule_run (separate from body status). */
|
|
475
|
+
export function setScheduleRunAction(args: {
|
|
476
|
+
run_id: string;
|
|
477
|
+
status: ScheduleActionStatus;
|
|
478
|
+
error?: string | null;
|
|
479
|
+
}): void {
|
|
480
|
+
ensureSchema();
|
|
481
|
+
db().prepare(
|
|
482
|
+
`UPDATE schedule_runs SET action_status = ?, action_error = ? WHERE id = ?`,
|
|
483
|
+
).run(args.status, args.error ?? null, args.run_id);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Zombie reconciliation ────────────────────────────────
|
|
487
|
+
|
|
488
|
+
/** Same idea as Job's reconcileStalePipelineRuns: any started+30s+
|
|
489
|
+
* schedule_run whose target is gone OR in terminal state → fix the
|
|
490
|
+
* DB row. Handles body_kind=pipeline (read JSON file) and
|
|
491
|
+
* body_kind=skill (read tasks table). connector_tool lands in phase 3. */
|
|
492
|
+
export function reconcileStaleScheduleRuns(): void {
|
|
493
|
+
try {
|
|
494
|
+
const stale = db().prepare(`
|
|
495
|
+
SELECT sr.id, sr.target_id, s.body_kind
|
|
496
|
+
FROM schedule_runs sr
|
|
497
|
+
JOIN schedules s ON s.id = sr.schedule_id
|
|
498
|
+
WHERE sr.status = 'started'
|
|
499
|
+
AND datetime(sr.started_at) < datetime('now', '-30 seconds')
|
|
500
|
+
`).all() as { id: string; target_id: string; body_kind: string }[];
|
|
501
|
+
|
|
502
|
+
if (stale.length === 0) return;
|
|
503
|
+
|
|
504
|
+
const pipelineDir = joinPath(getDataDir(), 'pipelines');
|
|
505
|
+
|
|
506
|
+
for (const row of stale) {
|
|
507
|
+
// Only fire action AFTER body actually transitioned to terminal.
|
|
508
|
+
// Previously this loop unconditionally called runActionForRunLazy
|
|
509
|
+
// even when reconcilePipelineRun left status='started' (long-running
|
|
510
|
+
// pipeline still in flight) — action then saw status='started' and
|
|
511
|
+
// marked itself 'skipped' permanently with "body status was started",
|
|
512
|
+
// and chat/email/telegram never fired even after body finished.
|
|
513
|
+
let settled = false;
|
|
514
|
+
if (row.body_kind === 'pipeline') {
|
|
515
|
+
settled = reconcilePipelineRun(row.id, row.target_id, pipelineDir);
|
|
516
|
+
} else if (row.body_kind === 'skill') {
|
|
517
|
+
settled = reconcileSkillRun(row.id, row.target_id);
|
|
518
|
+
} else if (row.body_kind === 'connector_tool') {
|
|
519
|
+
// connector_tool dispatch is in-memory async. If it's still
|
|
520
|
+
// 'started' 30s later it means forge crashed mid-call, or the
|
|
521
|
+
// tool itself is hanging. Either way: fail the run so future
|
|
522
|
+
// ticks aren't blocked by isScheduleBusy.
|
|
523
|
+
markRunStatus(row.id, 'failed');
|
|
524
|
+
console.warn(`[schedules] reconciled stale connector_tool schedule_run ${row.id} → failed (no in-memory progress after 30s)`);
|
|
525
|
+
settled = true;
|
|
526
|
+
} else {
|
|
527
|
+
markRunStatus(row.id, 'failed');
|
|
528
|
+
console.warn(`[schedules] reconciled schedule_run ${row.id} → failed (unsupported body_kind '${row.body_kind}')`);
|
|
529
|
+
settled = true;
|
|
530
|
+
}
|
|
531
|
+
// Lazy-imported to avoid a static cycle: action-runner imports from
|
|
532
|
+
// this file. Only when body actually settled — otherwise pipeline
|
|
533
|
+
// is still in flight and we'll catch it on a later tick.
|
|
534
|
+
if (settled) void runActionForRunLazy(row.id);
|
|
535
|
+
}
|
|
536
|
+
} catch (e) {
|
|
537
|
+
console.warn(`[schedules] reconcileStaleScheduleRuns failed: ${(e as Error).message}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function markRunStatus(runId: string, status: ScheduleRunStatus): void {
|
|
542
|
+
db().prepare(
|
|
543
|
+
`UPDATE schedule_runs SET status = ?, finished_at = datetime('now') WHERE id = ?`,
|
|
544
|
+
).run(status, runId);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Lazy-import dispatch into action-runner so reconciler can fire the
|
|
548
|
+
* action without creating a static cycle (action-runner depends on
|
|
549
|
+
* this file). Errors are swallowed; action_status will stay 'pending'
|
|
550
|
+
* if dispatch failed and the next reconciler tick will retry. */
|
|
551
|
+
function runActionForRunLazy(runId: string): void {
|
|
552
|
+
import('./action-runner').then(({ runAction }) => {
|
|
553
|
+
return runAction(runId);
|
|
554
|
+
}).catch((e) => {
|
|
555
|
+
console.warn(`[schedules] runAction(${runId}) crashed: ${(e as Error).message}`);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Returns true if the run was settled into a terminal state (caller
|
|
560
|
+
* may then fire action). Returns false if the pipeline is still
|
|
561
|
+
* in flight — caller MUST NOT fire action yet. */
|
|
562
|
+
function reconcilePipelineRun(
|
|
563
|
+
runId: string,
|
|
564
|
+
pipelineId: string,
|
|
565
|
+
pipelineDir: string,
|
|
566
|
+
): boolean {
|
|
567
|
+
const file = joinPath(pipelineDir, `${pipelineId}.json`);
|
|
568
|
+
if (!existsSync(file)) {
|
|
569
|
+
markRunStatus(runId, 'failed');
|
|
570
|
+
console.warn(`[schedules] reconciled zombie schedule_run ${runId} → failed (pipeline ${pipelineId} JSON gone)`);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const p = JSON.parse(readFileSync(file, 'utf8')) as { status?: string };
|
|
575
|
+
if (p.status && p.status !== 'running' && p.status !== 'pending') {
|
|
576
|
+
const mapped: ScheduleRunStatus =
|
|
577
|
+
p.status === 'done' || p.status === 'failed' || p.status === 'cancelled'
|
|
578
|
+
? p.status
|
|
579
|
+
: 'failed';
|
|
580
|
+
markRunStatus(runId, mapped);
|
|
581
|
+
console.warn(`[schedules] reconciled schedule_run ${runId} → ${mapped}`);
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
return false; // pipeline still running — don't fire action yet
|
|
585
|
+
} catch (e) {
|
|
586
|
+
console.warn(`[schedules] failed to read ${file}: ${(e as Error).message}`);
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Same contract as reconcilePipelineRun: true if settled, false if
|
|
592
|
+
* task still in flight (caller must NOT fire action yet). */
|
|
593
|
+
function reconcileSkillRun(runId: string, taskId: string): boolean {
|
|
594
|
+
try {
|
|
595
|
+
const t = db().prepare(
|
|
596
|
+
`SELECT status, result_summary FROM tasks WHERE id = ?`,
|
|
597
|
+
).get(taskId) as { status?: string; result_summary?: string } | undefined;
|
|
598
|
+
if (!t) {
|
|
599
|
+
markRunStatus(runId, 'failed');
|
|
600
|
+
console.warn(`[schedules] reconciled zombie schedule_run ${runId} → failed (task ${taskId} gone)`);
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
if (t.status && t.status !== 'running' && t.status !== 'queued') {
|
|
604
|
+
const mapped: ScheduleRunStatus =
|
|
605
|
+
t.status === 'done' || t.status === 'failed' || t.status === 'cancelled'
|
|
606
|
+
? t.status
|
|
607
|
+
: 'failed';
|
|
608
|
+
db().prepare(`UPDATE schedule_runs SET body_output = ? WHERE id = ?`).run(t.result_summary || null, runId);
|
|
609
|
+
markRunStatus(runId, mapped);
|
|
610
|
+
console.warn(`[schedules] reconciled schedule_run ${runId} → ${mapped} (skill task ${taskId})`);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
return false; // task still running / queued — don't fire action yet
|
|
614
|
+
} catch (e) {
|
|
615
|
+
console.warn(`[schedules] failed to read task row ${taskId}: ${(e as Error).message}`);
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|